Measurements
Creating measures, sending recorded video or images, and reading the result back.
A measurement goes through three states: created, started, then ended. You can drive it 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/rppg, /measures/cppg | List only your measures run from a camera (rppg) or from a contact sensor (cppg) |
GET | /measures/{measureId} | Read one measure |
GET | /measures/{measureId}/rppg, /measures/{measureId}/cppg | Read one ended measure, only if it is of that kind |
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/rppg/captures | Create and run, from raw captures you cropped yourself |
POST | /measures/rppg/signal, /measures/cppg/signal | Create and run, from a signal already extracted |
POST | /measures/{measureId}/rppg/video, …/rppg/images, …/rppg/captures, …/rppg/signal, …/cppg/signal | Run an existing measure, from the same sources |
GET | /external-users/{externalId} | Whether a person you identify by externalId has measurements, and a blood-pressure calibration |
The create-and-run routes are the ones to reach for. They answer 201 Created; running an existing measure answers 200 OK. Both return the measurement. The two-step form exists for when you need the identifier before you have the file — that is how the v1 widget works: your server creates the measure, and the widget runs it. A measure runs once: running one that has already started is refused with 400.
This page details the video and images routes. The bodies of the captures and signal routes are in the OpenAPI reference.
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, strs, physicalAge, faceBmi | Nothing — derived from the face alone |
bmi | weight and height |
bpClass | weight, height, age and sex |
healthScore | weight, height, age, sex and smokingStatus |
bp | externalId, and a calibration for that person |
bp is calibrated per person. It needs an externalId, and an earlier, complete measurement of that same person for which a reference blood pressure was entered — only the end-of-measurement form of the v1 widget records one today. Without it, bp comes back with NOT_CALIBRATED. GET /external-users/{externalId} answers { "externalId", "rppgCalibrated", "cppgCalibrated" }, or 404 when none of your measurements carries that identifier.
A missing field is not a failed measurement
Variables that could not be computed come back with an error code, usually one naming exactly what was missing, while the rest of the measurement finishes 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. What your account may receive is set on the account. These fields can only narrow it further, never widen it.Two more optional fields sit beside them. tags is a list of your own labels, up to 1000, returned with the measure and usable to filter the list. computeOptions adjusts how a variable is expressed: { "bpClass": { "scale": 2 } } returns bpClass on two levels — normal, or not — instead of three.
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, unwantedVariables | Comma-separated, or one part per value |
tags | Comma-separated, or one part per value |
computeOptions | The object, serialised as JSON |
`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: the whole request is refused with 400, and nothing is measured. The named-part form is what works.
The call below goes to Production. Send it to Test while you are still checking your 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, 201 Created, 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. image/jpeg, image/jpg, image/png, image/bmp, image/x-ms-bmp, image/webp, image/heic |
The other parts are those of the video route.
Images and video do not compute the same variables
A still image carries no pulse. From images, only faceBmi is computed — plus bmi, which needs only weight and height. Every variable read from the pulse comes back with MISSING_CAPTURES, and signal is null.
The reverse holds for a video: it yields every variable except faceBmi, which reads still images and comes back with MISSING_IMAGE.
Reading a measurement back
GET /measures/{measureId}
Authorization: Bearer <apiKey>
Measures belonging to another client return 404, exactly as a non-existent one does.
Listing your measurements
GET /measures
Authorization: Bearer <apiKey>
You only ever see your own. Another client’s measurements are not refused, they are simply absent.
| Parameter | Notes |
|---|---|
take | 1 to 1000. Defaults to 100. |
skip | 0 or more. Defaults to 0. |
filters | Restricts what is returned. See below. |
The list is not sorted, and its order is not guaranteed from one call to the next: to go through a large set page by page, narrow it with a filter rather than relying on skip alone. filters applies to GET /measures only — GET /measures/rppg and GET /measures/cppg accept it but do not apply it.
Filtering by tag
A filter is a condition — a field, an operator, and the values to compare it to:
{ "operator": "contains", "name": "tags", "values": ["cohort-a"] }
It travels as one query parameter, written either serialised or spread — both are accepted, and mean the same thing:
GET /measures?filters={"operator":"contains","name":"tags","values":["cohort-a"]}
GET /measures?filters[operator]=contains&filters[name]=tags&filters[values][0]=cohort-a
In a real URL the serialised form is percent-encoded like any query value; curl -G --data-urlencode 'filters={…}' does it for you.
tags holds several values, so a condition compares a list to a list. That is what the three operators are for:
| Operator | Keeps a measurement when its tags… | ["cohort-a", "pilot"] matches |
|---|---|---|
contains | carry every value you list | ["cohort-a"], ["cohort-a", "pilot"] |
overlaps | carry at least one of them | ["cohort-a"], ["pilot", "other"] |
contained-by | carry no tag outside the ones you list | ["cohort-a"], ["pilot"], [] |
| Field | Accepted values |
|---|---|
operator | contains, overlaps, contained-by |
name | tags |
values | One or more tags |
Combining conditions
To require several conditions at once, assemble them with and:
{ "operator": "and", "operands": [{ "operator": "contains", "name": "tags", "values": ["cohort-a"] }] }
and earns its place only once you have more than one condition — a single condition needs no wrapper.
Name a field once, and list every value
A field may appear in only one condition: two conditions ontags are refused with 400. This is not a limitation to work around — ask for several tags by listing them in values, which is what the operators above compare against.What is filterable is a closed list
name accepts tags and nothing else. An unknown field, an unknown operator, or an and carrying no condition is refused with 400 before anything is looked up — so a typo comes back as a rejected request, never as an empty page you might read as “no results”.Deleting a measurement
DELETE /measures/{measureId}
Authorization: Bearer <apiKey>
This removes the stored media — captures, images, video and signals — and then the measurement itself, and answers 200 OK with an empty body. A file that is already gone is not an error, so a deletion interrupted halfway can simply be issued again. Once a deletion has completed, the measure no longer exists, and a second call answers 404.
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",
"createdAt": "2026-08-24T14:57:44.000Z",
"startedAt": "2026-08-24T14:57:46.000Z",
"endedAt": "2026-08-24T14:58:22.000Z",
"desiredVariables": null,
"unwantedVariables": null,
"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_EXTERNAL_ID" }
},
"userData": {
"externalId": null, "sex": "F", "age": 35,
"weight": 58, "height": 171, "smokingStatus": "non-smoker"
},
"tags": ["cohort-a"]
}
A measure that has not ended stops at its state: a created one carries neither startedAt nor anything after it, a started one has no endedAt, returnedVariables, signal or variables. userData is null when none was given, and a field you did not send is null inside it; a dateOfBirth comes back as the age it gave.
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 you asked for
It is not a processing failure. An account on which no variable has been enabled ends every one of its measurements with an empty list — even when the capture went perfectly.
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" } |
scale gives the range of level: 1 to 5 for strs, 1 to 3 for bpClass — or 1 to 2 when you asked for two levels.
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")
Error codes
An error is a code to act on, not a message to show.
| Code | What it says |
|---|---|
CONFORMITY_FACE_PRESENCE, CONFORMITY_FACE_SIZE, CONFORMITY_INITIALISATION, CONFORMITY_LOW_FPS, CONFORMITY_MISSING_SIGNAL, CONFORMITY_MUCH_VARIATIONS, CONFORMITY_ONDULATIONS, CONFORMITY_POOR_LIGHT | The capture itself: the face, its size, the frame rate, the light, the stability of the signal |
MISSING_USER_DATA, MISSING_USER_DATA_AGE, MISSING_USER_DATA_EXTERNAL_ID, MISSING_USER_DATA_HEIGHT, MISSING_USER_DATA_SEX, MISSING_USER_DATA_SMOKING_STATUS, MISSING_USER_DATA_WEIGHT | A field of userData the variable needs was not sent |
MISSING_CAPTURES, MISSING_IMAGE, MISSING_SIGNAL, EXCEEDING_MAX_IMAGE, INVALID_IMAGE_SIZE, INVALID_IMAGE_TYPE | The source sent cannot yield this variable |
MISSING_BP_CLASSIFICATION, MISSING_PROCESSOR, NOT_CALIBRATED | Something the variable depends on is missing — for NOT_CALIBRATED, the person’s calibration |
BAD_RMSSD, OUT_OF_RANGE, PROCESSOR_FAIL, UNKNOWN | The computation refused the input, or did not complete |
CREDIT_EXCEEDED | Your account has no credit left for the month; the measurement is not charged |
The quality indicator
"signal": { "qualityScore": { "value": 100, "error": null } }
signal is null when the measurement had no pulse signal to assess — a measurement made from images, for instance.
Do not build a threshold on it
qualityScore reports on the signal itself, not on the measurement as a whole, and it is not a grade of how exploitable that measurement was. A rule of the form “reject below n” reads it for something it does not say.To judge whether a measurement is usable, read the individual variables and their error codes: a variable that could not be computed names the reason itself, which is the information a threshold was standing in for.
Work that out on Test data first. Rights and credits are set per account, so what a Test account returns is not necessarily what your Production account will return. Environments sets out the separation.