Measurements

Creating measures, sending recorded video or images, and reading the result back.

A measurement moves through three states — created, started, ended — and the API lets you drive it either in one call or in two.

The routes

MethodPathWhat it does
POST/measuresCreate a measure without running it
GET/measuresList your measures
GET/measures/{measureId}Read one measure
DELETE/measures/{measureId}Delete a measure and its media
POST/measures/rppg/videoCreate and run, from a video file
POST/measures/rppg/imagesCreate and run, from image files
POST/measures/{measureId}/rppg/videoRun an existing measure from a video file
POST/measures/{measureId}/rppg/imagesRun an existing measure from image files

The create-and-execute routes are the ones to reach for. The two-step form exists for when you need the identifier before you have the media — the widget uses it that way.

Describing the person

Several variables cannot be computed from the signal alone. userData is where you say what is needed.

FieldTypeBounds
externalIdstringUp to 300 characters. Your own identifier for the person.
sex"M" | "F"
ageinteger18 to 110
dateOfBirthdate stringAn alternative to age; the same bounds apply
weightinteger20 to 200, in kilograms
heightinteger100 to 250, in centimetres
smokingStatus"smoker" | "non-smoker"

Every field is optional. What you omit simply narrows what can be computed:

VariableNeeds
hr, br, hrvNothing — derived from the signal alone
bmiweight and height
bp, bpClass, healthScore, strsThe demographic fields; a missing one is reported per variable

A missing field is not a failed measurement

Variables that could not be computed come back carrying an error code — typically one naming exactly what was missing — while the rest of the measurement completes normally. You do not lose the heart rate because you did not know the person’s height.

Choosing variables

Two optional arrays narrow what is computed:

{
  "desiredVariables": ["hr", "br"],
  "unwantedVariables": ["faceBmi"]
}

Omit both and the measurement computes everything your account is entitled to.

These narrow; they do not grant

Asking for a variable your account is not entitled to does not enable it. Entitlement is configured on the client account, and these fields can only reduce what that entitlement already allows.

Sending a recorded video

POST /measures/rppg/video
Authorization: Bearer <apiKey>
Content-Type: multipart/form-data
PartNotes
videoUp to 100 MB. video/mp4, video/webm, video/x-msvideo, video/vnd.avi, video/quicktime, video/mov
userData[…]Named parts, one per field
desiredVariablesComma-separated
tagsComma-separated

`userData` travels as named parts

Write userData[weight], userData[height], userData[age] — each as its own multipart field.

A single userData part containing serialised JSON is not accepted on these routes, and this is the most common cause of a measurement that mysteriously returns no bmi. The named-part form is what works.

The call below goes to Production. Send it to Test while you are still validating a pipeline — measurements made there never appear in your Production data.

EnvironmentAPI base
Productionhttps://api.saphere.ai
Testhttps://api.test.saphere.ai
curl -sX POST https://api.saphere.ai/measures/rppg/video \
  -H "Authorization: Bearer $SAPHERE_API_KEY" \
  -F "video=@./capture.mp4;type=video/mp4" \
  -F "userData[sex]=F" \
  -F "userData[age]=35" \
  -F "userData[weight]=58" \
  -F "userData[height]=171" \
  -F "userData[smokingStatus]=non-smoker" \
  -F "tags=cohort-a,pilot"

The response is the completed measurement — the same shape the widget delivers.

Sending images

POST /measures/rppg/images
PartNotes
imagesAt most 3 files. Each between 4 KB and 30 MB.

Everything else matches the video route.

Reading a measurement back

GET /measures/{measureId}
Authorization: Bearer <apiKey>

Measures belonging to another client return 404, in plain text, exactly as a non-existent one does.

Deleting a measurement

DELETE /measures/{measureId}
Authorization: Bearer <apiKey>

This removes the stored media — captures, images, video, signals — and then the measurement row. The call is safely repeatable: a missing object is not an error, so a delete interrupted halfway can simply be issued again.

This is the call that keeps a privacy promise

If your product tells users that a scan leaves nothing behind, this is what makes that true. Nothing else removes the media.

The result

{
  "id": "3f1a5c88-0b2d-4e6a-9c11-7d5e8a2b4f60",
  "status": "ended",
  "type": "rppg",
  "createdAt": "2026-08-24T14:57:44.000Z",
  "startedAt": "2026-08-24T14:57:46.000Z",
  "endedAt": "2026-08-24T14:58:22.000Z",
  "returnedVariables": ["hr", "br", "strs", "bp"],
  "signal": { "qualityScore": { "value": 100, "error": null } },
  "variables": {
    "hr":   { "value": { "mean": 72.4 }, "error": null },
    "br":   { "value": { "mean": 15.1 }, "error": null },
    "strs": { "value": { "level": 2, "scale": { "min": 1, "max": 5 } }, "error": null },
    "bp":   { "value": null, "error": "MISSING_USER_DATA_AGE" }
  },
  "userData": { "sex": "F", "weight": 58, "height": 171 },
  "tags": ["cohort-a"]
}

returnedVariables is an entitlement, not an outcome

This is the field most often misread. It lists the variables your client account is allowed to receive, narrowed by any desiredVariables / unwantedVariables you passed. It does not list what succeeded.

`returnedVariables: []` means the account grants nothing

It is not a processing failure. A freshly created client has no variables enabled, so every one of its measurements ends with an empty list — even when the signal was perfect and qualityScore came back 100.

The signature to recognise: a present signal, and empty variables. Look at the account, not at the capture.

Value shapes

Every variable is one of two things: a value with error: null, or value: null with an error code.

VariablesShape on success
hr, br, hrv{ "value": { "mean": 72.4 }, "error": null }
strs, bpClass{ "value": { "level": 2, "scale": { "min": 1, "max": 5 } }, "error": null }
bp{ "value": { "systole": 118, "diastole": 76 }, "error": null }
physicalAge, bmi, faceBmi, healthScore{ "value": 24.1, "error": null }
any{ "value": null, "error": "CONFORMITY_POOR_LIGHT" }

Always branch on error first:

const hr = result.variables.hr
if (hr?.error === null)
    console.log("heart rate:", hr.value.mean)
else if (hr)
    console.warn("heart rate unavailable:", hr.error)
else
    console.warn("heart rate not granted to this account")

The quality indicator

"signal": { "qualityScore": { "value": 100, "error": null } }

It is binary

qualityScore is 100 or 0, and it answers exactly one question: did the signal yield at least one window usable for blood-pressure calibration?

It is not a score out of a hundred, and it does not grade how exploitable the measurement was. A threshold rule built on it has only two possible outcomes, which is almost certainly not what you intended.

To judge whether a measurement is usable, read the individual variables and their error codes.

Build that judgement against Test data first: entitlements and quota are set per account, so what a Test client returns is not necessarily what your Production client will. Environments sets out the separation.