Mesures

Créer des mesures, envoyer une vidéo ou des images enregistrées, et relire le résultat.

Une mesure traverse trois états : created, started, puis ended. Vous pouvez la mener en un appel, ou en deux.

Les routes

MéthodeCheminCe qu’elle fait
POST/measuresCréer une mesure sans l’exécuter
GET/measuresLister vos mesures
GET/measures/rppg, /measures/cppgLister seulement vos mesures faites depuis une caméra (rppg) ou depuis un capteur au contact (cppg)
GET/measures/{measureId}Lire une mesure
GET/measures/{measureId}/rppg, /measures/{measureId}/cppgLire une mesure terminée, seulement si elle est de cette sorte
DELETE/measures/{measureId}Supprimer une mesure et ses médias
POST/measures/rppg/videoCréer et exécuter, depuis un fichier vidéo
POST/measures/rppg/imagesCréer et exécuter, depuis des images
POST/measures/rppg/capturesCréer et exécuter, depuis des captures brutes que vous avez recadrées vous-même
POST/measures/rppg/signal, /measures/cppg/signalCréer et exécuter, depuis un signal déjà extrait
POST/measures/{measureId}/rppg/video, …/rppg/images, …/rppg/captures, …/rppg/signal, …/cppg/signalExécuter une mesure existante, depuis les mêmes sources
GET/external-users/{externalId}Savoir si une personne que vous identifiez par externalId a des mesures, et un calibrage de tension

Les routes « créer et exécuter » sont celles vers lesquelles se tourner. Elles répondent 201 Created ; exécuter une mesure existante répond 200 OK. Les deux rendent la mesure. La forme en deux temps existe pour les cas où il vous faut l’identifiant avant d’avoir le média — c’est ainsi que fonctionne le widget v1 : votre serveur crée la mesure, et le widget l’exécute. Une mesure ne s’exécute qu’une fois : en exécuter une déjà démarrée est refusé par un 400.

Cette page détaille les routes vidéo et images. Les corps des routes de captures et de signal sont dans la référence OpenAPI.

Décrire la personne

Plusieurs variables ne se calculent pas à partir du seul signal. userData est l’endroit où vous dites ce qu’il faut.

ChampTypeBornes
externalIdchaîneJusqu’à 300 caractères. Votre propre identifiant pour la personne.
sex"M" | "F"
ageentier18 à 110
dateOfBirthchaîne de dateUne alternative à age ; les mêmes bornes s’appliquent
weightentier20 à 200, en kilogrammes
heightentier100 à 250, en centimètres
smokingStatus"smoker" | "non-smoker"

Tous les champs sont facultatifs. Ce que vous omettez ne fait que réduire ce qui peut être calculé :

VariableA besoin de
hr, br, hrv, strs, physicalAge, faceBmiRien — tirées du seul visage
bmiweight et height
bpClassweight, height, age et sex
healthScoreweight, height, age, sex et smokingStatus
bpexternalId, et un calibrage pour cette personne

bp se calibre personne par personne. Elle demande un externalId, et une mesure antérieure et complète de cette même personne pour laquelle une tension de référence a été saisie — seul le formulaire de fin de mesure du widget v1 en enregistre une aujourd’hui. Sans elle, bp revient avec NOT_CALIBRATED. GET /external-users/{externalId} répond { "externalId", "rppgCalibrated", "cppgCalibrated" }, ou 404 quand aucune de vos mesures ne porte cet identifiant.

Un champ manquant n'est pas une mesure ratée

Les variables qui n’ont pas pu être calculées reviennent avec un code d’erreur, le plus souvent un code nommant exactement ce qui manquait, tandis que le reste de la mesure se termine normalement. Vous ne perdez pas la fréquence cardiaque parce que vous ignoriez la taille de la personne.

Choisir les variables

Deux tableaux facultatifs réduisent ce qui est calculé :

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

Omettez les deux et la mesure calcule tout ce à quoi votre compte a droit.

Ils réduisent ; ils n'accordent pas

Demander une variable à laquelle votre compte n’a pas droit ne l’active pas. Le droit se configure sur le compte client, et ces champs ne peuvent que réduire ce que ce droit permet déjà.

Deux autres champs facultatifs se tiennent à côté. tags est une liste d’étiquettes à vous, 1000 au plus, rendue avec la mesure et qui sert à filtrer la liste. computeOptions règle la façon dont une variable s’exprime : { "bpClass": { "scale": 2 } } rend bpClass sur deux niveaux — normal, ou non — au lieu de trois.

Envoyer une vidéo enregistrée

POST /measures/rppg/video
Authorization: Bearer <apiKey>
Content-Type: multipart/form-data
PartieRemarques
videoJusqu’à 100 Mo. video/mp4, video/webm, video/x-msvideo, video/vnd.avi, video/quicktime, video/mov
userData[…]Parties nommées, une par champ
desiredVariables, unwantedVariablesSéparées par des virgules, ou une partie par valeur
tagsSéparés par des virgules, ou une partie par valeur
computeOptionsL’objet, sérialisé en JSON

`userData` voyage en parties nommées

Écrivez userData[weight], userData[height], userData[age] — chacun comme sa propre partie multipart.

Une partie userData unique contenant du JSON sérialisé n’est pas acceptée sur ces routes : la requête entière est refusée par un 400, et rien n’est mesuré. C’est la forme en parties nommées qui fonctionne.

L’appel ci-dessous s’adresse à la Production. Envoyez-le au Test tant que vous vérifiez encore votre chaîne : les mesures qui y sont faites n’apparaissent jamais dans vos données de Production.

EnvironnementBase de l'API
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"

La réponse, 201 Created, est la mesure aboutie — la même forme que celle livrée par le widget.

Envoyer des images

POST /measures/rppg/images
PartieRemarques
images3 fichiers au plus. Chacun entre 4 Kio et 30 Mo. image/jpeg, image/jpg, image/png, image/bmp, image/x-ms-bmp, image/webp, image/heic

Les autres parties sont celles de la route vidéo.

Images et vidéo ne calculent pas les mêmes variables

Une image fixe ne porte pas de pouls. Depuis des images, seule faceBmi est calculée — plus bmi, qui ne demande que weight et height. Toute variable lue sur le pouls revient avec MISSING_CAPTURES, et signal vaut null.

L’inverse vaut pour une vidéo : elle rend toutes les variables sauf faceBmi, qui lit des images fixes et revient avec MISSING_IMAGE.

Relire une mesure

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

Les mesures appartenant à un autre client rendent 404, exactement comme une mesure inexistante.

Lister ses mesures

GET /measures
Authorization: Bearer <apiKey>

Vous ne voyez jamais que les vôtres. Les mesures d’un autre client ne sont pas refusées : elles sont simplement absentes.

ParamètreRemarques
takeDe 1 à 1000. Vaut 100 par défaut.
skip0 ou plus. Vaut 0 par défaut.
filtersRestreint ce qui est rendu. Voir ci-dessous.

La liste n’est pas triée, et son ordre n’est pas garanti d’un appel à l’autre : pour parcourir un grand ensemble page par page, réduisez-le par un filtre plutôt que de vous fier à skip seul. filters ne s’applique qu’à GET /measures — GET /measures/rppg et GET /measures/cppg l’acceptent sans l’appliquer.

Filtrer par étiquette

Un filtre est une condition — un champ, un opérateur, et les valeurs auxquelles le comparer :

{ "operator": "contains", "name": "tags", "values": ["cohort-a"] }

Il voyage en un seul paramètre de query, écrit sérialisé ou éclaté — les deux sont acceptés et disent la même chose :

GET /measures?filters={"operator":"contains","name":"tags","values":["cohort-a"]}
GET /measures?filters[operator]=contains&filters[name]=tags&filters[values][0]=cohort-a

Dans une vraie adresse, la forme sérialisée est encodée en pourcentage comme toute valeur de query ; curl -G --data-urlencode 'filters={…}' s’en charge pour vous.

tags porte plusieurs valeurs : une condition y compare donc une liste à une liste. C’est ce à quoi servent les trois opérateurs :

OpérateurRetient une mesure dont les étiquettes…["cohort-a", "pilot"] retient
containsportent toutes les valeurs listées["cohort-a"], ["cohort-a", "pilot"]
overlapsen portent au moins une["cohort-a"], ["pilot", "other"]
contained-byne portent aucune étiquette hors de celles listées["cohort-a"], ["pilot"], []
ChampValeurs acceptées
operatorcontains, overlaps, contained-by
nametags
valuesUne étiquette ou plusieurs

Réunir plusieurs conditions

Pour en exiger plusieurs à la fois, réunissez-les par un and :

{ "operator": "and", "operands": [{ "operator": "contains", "name": "tags", "values": ["cohort-a"] }] }

and ne se justifie qu’à partir de deux conditions — une condition seule n’a besoin d’aucune enveloppe.

Nommez un champ une fois, et listez toutes ses valeurs

Un champ ne peut figurer que dans une condition : deux conditions sur tags sont refusées par un 400. Ce n’est pas une limite à contourner — pour demander plusieurs étiquettes, listez-les dans values, que les opérateurs ci-dessus comparent.

Ce qui est filtrable est une liste fermée

name accepte tags et rien d’autre. Un champ inconnu, un opérateur inconnu ou un and sans condition sont refusés par un 400 avant toute recherche — une faute de frappe revient donc en requête rejetée, jamais en page vide qu’on lirait comme « aucun résultat ».

Supprimer une mesure

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

Cet appel retire les médias conservés — captures, images, vidéo et signaux — puis la mesure elle-même, et répond 200 OK avec un corps vide. Un objet absent n’est pas une erreur, de sorte qu’une suppression interrompue à mi-course se relance simplement. Une fois une suppression menée à son terme, la mesure n’existe plus, et un second appel répond 404.

C'est l'appel qui tient une promesse de confidentialité

Si votre produit dit aux utilisateurs qu’un scan ne laisse rien derrière lui, c’est cet appel qui le rend vrai. Rien d’autre ne retire les médias.

Le résultat

{
  "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"]
}

Une mesure qui n’est pas terminée s’arrête à son état : une mesure created ne porte ni startedAt ni rien de ce qui suit, une mesure started n’a ni endedAt, ni returnedVariables, ni signal, ni variables. userData vaut null quand rien n’a été fourni, et un champ que vous n’avez pas envoyé y vaut null ; une dateOfBirth revient sous la forme de l’age qu’elle donnait.

returnedVariables est un droit, non un résultat

C’est le champ le plus souvent mal lu. Il énumère les variables que votre compte client a le droit de recevoir, réduites par les desiredVariables / unwantedVariables que vous avez passés. Il n’énumère pas ce qui a réussi.

`returnedVariables: []` signifie que le compte n'accorde rien de ce que vous demandiez

Ce n’est pas un échec de traitement. Un compte sur lequel aucune variable n’a été activée termine toutes ses mesures sur une liste vide — même quand la capture s’est parfaitement déroulée.

La signature à reconnaître : un signal présent, et des variables vides. Regardez le compte, pas la capture.

Formes des valeurs

Chaque variable est l’une de deux choses : une valeur avec error: null, ou value: null accompagné d’un code d’erreur.

VariablesForme en cas de succès
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 }
n’importe laquelle{ "value": null, "error": "CONFORMITY_POOR_LIGHT" }

scale donne l’étendue de level : de 1 à 5 pour strs, de 1 à 3 pour bpClass — ou de 1 à 2 quand vous avez demandé deux niveaux.

Testez toujours error en premier :

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")

Codes d’erreur

Une erreur est un code sur lequel agir, non un message à afficher.

CodeCe qu’il dit
CONFORMITY_FACE_PRESENCE, CONFORMITY_FACE_SIZE, CONFORMITY_INITIALISATION, CONFORMITY_LOW_FPS, CONFORMITY_MISSING_SIGNAL, CONFORMITY_MUCH_VARIATIONS, CONFORMITY_ONDULATIONS, CONFORMITY_POOR_LIGHTLa capture elle-même : le visage, sa taille, la cadence, la lumière, la stabilité du 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_WEIGHTUn champ de userData dont la variable a besoin n’a pas été envoyé
MISSING_CAPTURES, MISSING_IMAGE, MISSING_SIGNAL, EXCEEDING_MAX_IMAGE, INVALID_IMAGE_SIZE, INVALID_IMAGE_TYPELa source envoyée ne permet pas cette variable
MISSING_BP_CLASSIFICATION, MISSING_PROCESSOR, NOT_CALIBRATEDIl manque ce dont la variable dépend — pour NOT_CALIBRATED, le calibrage de la personne
BAD_RMSSD, OUT_OF_RANGE, PROCESSOR_FAIL, UNKNOWNLe calcul a refusé l’entrée, ou n’a pas abouti
CREDIT_EXCEEDEDVotre compte n’a plus de crédit pour le mois ; la mesure n’est pas décomptée

L’indicateur de qualité

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

signal vaut null quand la mesure n’avait aucun signal de pouls à évaluer — une mesure faite depuis des images, par exemple.

N'y bâtissez pas un seuil

qualityScore porte sur le signal lui-même, non sur la mesure dans son ensemble, et il ne juge pas à quel point cette mesure était exploitable. Une règle de la forme « rejeter en dessous de n » lui fait dire ce qu’il ne dit pas.

Pour juger si une mesure est exploitable, lisez les variables une à une et leurs codes d’erreur : une variable qui n’a pas pu être calculée nomme elle-même le motif, qui est l’information qu’un seuil remplaçait.

Établissez ce jugement sur des données de Test d’abord. Les droits et les crédits se règlent par compte, si bien que ce que rend un compte de Test n’est pas nécessairement ce que rendra votre compte de Production. Environnements expose la séparation.