Quickstart
From an empty page to a finished measurement, in about ten minutes.
This page walks through the shortest complete integration: an endpoint on your server that issues tokens, the widget on a page, and a result printed in your browser console.
You need an account and its API key. If you do not have one yet, contact i-Virtual for a trial.
Everything below runs on Test, which is where a first integration belongs. Production runs the same integration, at different addresses. See Environments.
The API key never goes in a browser
Your API key opens every route of the API and never expires. The only way to revoke it is to deactivate your account, which cuts off all 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
At the start of every measurement, the widget asks your application for an access token. Your server answers by calling the API with your key.
Point it at the environment your 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: "measurement.scan", 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 that it works 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": "measurement.scan", "maxAttempts": 1 }],
"expiresAt": "2026-08-24T15:12:44.000Z",
"createdAt": "2026-08-24T14:57:44.000Z"
}
`value` is shown once
Only a fingerprint of the token is stored, never the token itself. This response is the one and only time the API hands youvalue. A lost token cannot be recovered. It is revoked and replaced.2. Put the widget on a page
The widget is a single JavaScript file served from our CDN. It attaches itself to a div you provide, and fills it.
The CDN address you load it from selects the environment. The widget works out the measurement service from that address on its own, so code served by the Test CDN talks to the Test service and never to Production. Your token endpoint 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 any other kind of address, and there is no way around it.
3. Run a measurement
Open the page. You should see the introduction screens, then a consent screen, then the camera.
Sit in even light, facing the camera, and stay reasonably still for thirty seconds. Watch the console: the widget reports every step.
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 finished 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 one of two things: a value with error: null, or value: null with an error code saying what went wrong. Always test error before reading value.
Empty `returnedVariables`?
That is not a failure. It means your account has not been granted any variables yet, which is the normal state of a brand-new test account. Ask i-Virtual to enable the ones you need.