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
| Method | Path | What it does |
|---|---|---|
POST | /measures | Create a measure without running it |
GET | /measures | List your measures |
GET | /measures/{measureId} | Read one measure |
DELETE | /measures/{measureId} | Delete a measure and its media |
POST | /measures/rppg/video | Create and run, from a video file |
POST | /measures/rppg/images | Create and run, from image files |
POST | /measures/{measureId}/rppg/video | Run an existing measure from a video file |
POST | /measures/{measureId}/rppg/images | Run 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.
| Field | Type | Bounds |
|---|---|---|
externalId | string | Up to 300 characters. Your own identifier for the person. |
sex | "M" | "F" | |
age | integer | 18 to 110 |
dateOfBirth | date string | An alternative to age; the same bounds apply |
weight | integer | 20 to 200, in kilograms |
height | integer | 100 to 250, in centimetres |
smokingStatus | "smoker" | "non-smoker" |
Every field is optional. What you omit simply narrows what can be computed:
| Variable | Needs |
|---|---|
hr, br, hrv | Nothing — derived from the signal alone |
bmi | weight and height |
bp, bpClass, healthScore, strs | The 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
| Part | Notes |
|---|---|
video | Up to 100 MB. video/mp4, video/webm, video/x-msvideo, video/vnd.avi, video/quicktime, video/mov |
userData[…] | Named parts, one per field |
desiredVariables | Comma-separated |
tags | Comma-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.
| Environment | API base |
|---|---|
| Production | https://api.saphere.ai |
| Test | https://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
| Part | Notes |
|---|---|
images | At 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.
| Variables | Shape 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.