Quickstart
From an empty page to a completed measurement, in about ten minutes.
This walks through the shortest complete integration: a token endpoint on your server, the widget on a page, and a result in your console.
You will need a client account and its API key. If you do not have one, contact i-Virtual for a trial.
Everything below uses Test, which is where a first integration belongs. Production is the same code against a different set of hosts — see Environments.
The API key never goes in a browser
Your API key opens every route of the API, has no expiry, and is revoked only by deactivating your client account — which cuts every one of your integrations at once. It belongs on your server and nowhere else. The widget is built so that it never needs to hold it.1. Stand up a token endpoint
The widget asks your application for an access token at the start of every measurement. Your server answers by calling the API with your key.
Point it at the environment your Test API key belongs to:
| Environment | API base |
|---|---|
| Production | https://api.saphere.ai |
| Test | https://api.test.saphere.ai |
// server.js — Node 20+, Express 4
import express from "express"
const app = express()
const API_BASE = "https://api.test.saphere.ai" // Production: https://api.saphere.ai
const API_KEY = process.env.SAPHERE_API_KEY // never hard-code this
app.post("/api/saphere-token", async (request, response) => {
// Authenticate YOUR user here first. This endpoint mints a credential:
// leaving it open lets anyone spend your measurement quota.
const created = await fetch(`${API_BASE}/access-tokens`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`
},
body: JSON.stringify({
description: "web widget",
// maxAttempts: 1 makes the token single-use. Omitting it would
// produce a token replayable until it expires.
permissions: [{ name: "websocket.measurement", maxAttempts: 1 }],
duration: "15min"
})
})
if (!created.ok)
return response.status(502).json({ error: "could not mint a token" })
// Relay the whole answer: the widget reads `value`, which is the bearer.
// `id` and `expiresAt` open nothing and are safe to keep in your logs.
response.json(await created.json())
})
app.listen(3000)
Check it before going further:
curl -sX POST http://localhost:3000/api/saphere-token | jq
{
"id": "8f1c2d34-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"value": "ivat-XCq7…",
"description": "web widget",
"permissions": [{ "name": "websocket.measurement", "maxAttempts": 1 }],
"expiresAt": "2026-08-24T15:12:44.000Z",
"createdAt": "2026-08-24T14:57:44.000Z"
}
`value` is shown once
Only a hash of the token is stored. This response is the only time the API will ever hand youvalue. A lost token is not recovered — it is revoked and replaced.2. Put the widget on a page
The widget is an ES module served from the CDN. It mounts into a div you own and fills it.
The CDN you load it from is what selects the environment — the widget derives the measurement API from it on its own, so a bundle served by the Test CDN talks to the Test measurement service and never to Production. Your token endpoint above is the one part that does not follow automatically: you point it at the matching API yourself.
| Environment | Module URL |
|---|---|
| Production | https://cdn.saphere.ai/saphere-scan/v2/main.js |
| Test | https://cdn.test.saphere.ai/saphere-scan/v2/main.js |
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Saphere Scan</title>
<style>
html, body { margin: 0; height: 100%; }
/* The widget fills its container, so the container needs a height. */
#scan { height: 100dvh; }
/* On a wide screen, keep it in a phone-shaped column. */
@media (min-width: 40rem) { #scan { aspect-ratio: 9 / 16; margin: 0 auto; } }
</style>
</head>
<body>
<div id="scan"></div>
<script type="module">
import SaphereScan from "https://cdn.test.saphere.ai/saphere-scan/v2/main.js"
const instance = SaphereScan.create("#scan", {
lang: "en",
proxy: {
retrieveAccessToken: {
strategy: "delegate",
url: "/api/saphere-token"
}
},
onEvent: event => {
console.log(event.type, event)
if (event.type === "measure:result")
console.log("variables:", event.result.variables)
if (event.type === "measure:failed")
console.warn("measurement failed:", event.code)
}
})
await instance.bootstrap()
</script>
</body>
</html>
Serve that page over HTTPS or from localhost. Browsers refuse camera access on an insecure origin, and there is no way around it.
3. Run a measurement
Open the page. You should see the onboarding screens, then a consent screen, then the camera.
Sit in even light, facing the camera, and hold reasonably still for thirty seconds. Watch the console: the widget narrates the whole journey.
widget:ready
screen:enter { path: "/onboarding/0", name: "onboarding" }
detector:loading
detector:loaded
consent:accepted
camera:requesting
camera:ready { width: 640, height: 480, frameRate: 30 }
placement:started
placement:accepted
token:requesting { strategy: "delegate" }
token:retrieved
transport:connected
measure:start
measure:progress { percent: 1 }
…
measure:captures-sent { totalCaptures: 900, totalImages: 1 }
measure:result { result: { … } }
4. Read the result
event.result is the completed measurement. The part you want is variables:
{
"status": "ended",
"returnedVariables": ["hr", "br", "strs"],
"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 }
}
}
Every variable is either a value with error: null, or value: null with an error code naming what went wrong. Always branch on error before reading value.
Empty `returnedVariables`?
That is not a processing failure. It means your client account has not been granted any variables yet — the usual state of a brand-new test client. Ask i-Virtual to enable the ones you need.