API Reference
Liveness session and analysis endpoint reference
Three groups of endpoints, each with its own authentication:
| Group | Prefix | Authentication | Rate limit |
|---|---|---|---|
| Integrator | /liveness/* | API key, publishable or secret | 200 requests per minute per key |
| Session list | GET /liveness/sessions | API key, secret only | 60 requests per minute per key |
| API-only analysis | /liveness/analyze | API key, secret only | 60 requests per minute per key |
| Webhook configuration | /liveness/webhook | API key, secret only | 30 requests per minute per key |
| Subject | /liveness/public/sessions/:id/* | Authorization: Bearer <sessionToken> (no API key) | 60 requests per minute per address, with tighter per-endpoint limits |
Publishable keys are subject to the origin rules: use them from the browser, and a secret key from your server. The subject group is the private contract between the hosted capture page and the platform; it is not an integration surface, and it is documented here only so you know what the capture link talks to. To analyse media captured by your own application, use POST /liveness/analyze.
Errors
Every error body is { "error": "<message>" }, sometimes with extra fields (issues for validation, status for state conflicts, allowed for media types, faces for reference photos).
| Status | When |
|---|---|
400 | Missing multipart field, meta is not valid JSON, no video and no frame, reference required but absent |
401 | Missing or invalid API key; missing session token on subject endpoints |
403 | Publishable key on a secret-only endpoint, or origin not allowed |
404 | Session not found, not owned by your organisation, or wrong session token; artifact not found or not served to this key; no webhook configured |
409 | State conflict: the session already received a capture, or is terminal (status in the body says which) |
410 | Session expired (subject endpoints); artifact purged ({ "error": "Artifact purged", "purged": true }) |
413 | Upload over its cap (see below) |
422 | Validation failed (issues lists the fields), unknown profile, unsupported media type, too many frames, duplicate or unnamed frame, reference with no face or several faces, returnUrl on an unregistered origin, malformed attestation field |
429 | Rate limit hit |
503 | Liveness processing unavailable (the pipeline could not be started) |
Upload limits
| Field | Types | Cap |
|---|---|---|
video | video/webm, video/mp4, video/quicktime, video/x-matroska | 24 MB |
frames | image/jpeg, image/png, image/webp | 600 KB each, 16 files |
reference | image/jpeg, image/png, image/webp | 5 MB |
The whole multipart body is capped at 20 MB.
Integrator endpoints
GET /liveness/profiles
Lists the named profiles with their stage ids.
Authentication: API key (publishable or secret)
{
"profiles": [
{ "name": "mobile-active-qr", "description": "Phone via QR, timed prompts then the steer targets (reinforced)", "device": "phone", "handoff": "qr",
"mode": "active", "strictness": "standard", "reference": { "source": "none", "required": false, "forensics": false },
"stages": [ { "id": "media.probe", "kind": "media", "required": true } ] }
]
}GET /liveness/status
Readiness of the providers the pipeline depends on. Poll it in your health checks.
Authentication: API key (publishable or secret)
{ "ffmpeg": { "ffmpeg": true, "ffprobe": true }, "faceMatch": { "available": true }, "forensics": "configured", "execution": "detached", "inFlight": 2 }forensics is the forensic stage: configured or unavailable. faceMatch.available says whether the face engine is ready. execution is detached (the capture endpoint answers 202 and the pipeline runs in the background) or inline; inFlight counts sessions currently being analysed. A missing provider does not abort a session: see degraded modes.
POST /liveness/sessions
Creates a session and its one-time subject token.
Authentication: API key (publishable or secret)
| Field | Type | Required | Description |
|---|---|---|---|
profile | string | No | Profile name, default mobile-quick-qr |
overrides | object | No | Whitelisted profile overrides; unknown keys are rejected |
externalRef | string | No | Your own reference, up to 100 characters, echoed in the report |
metadata | object | No | Free JSON echoed in the report, up to 1 KB serialised |
returnUrl | string | No | Where the capture page offers to send the subject once the session is terminal. Must be an https URL on an origin registered for this key: see return URL |
returnAuto | boolean | No | Default false. true counts down and returns on its own instead of waiting for a tap. Ignored without returnUrl |
curl -X POST https://api.verifiables.com/liveness/sessions \
-H "Authorization: Bearer oat_your_secret_key" \
-H "Content-Type: application/json" \
-d '{ "profile": "mobile-active-idmatch", "externalRef": "onboarding-2026-000482" }'Response 201:
{
"sessionId": "5b1f0c1e-8d3a-4f6b-9c2e-7a1d2e3f4a5b", "sessionToken": "Qm9vSk1yZ2FsYVBwd0tZbnp2ZExjM3ZnbXRxM2xBNXo",
"expiresAt": "2026-09-03T10:22:04.000Z", "expiresIn": 600, "profile": "mobile-active-idmatch",
"captureUrl": "https://verify.verifiables.com/liveness/s/#s=5b1f0c1e-8d3a-4f6b-9c2e-7a1d2e3f4a5b.Qm9vSk1yZ2FsYVBwd0tZbnp2ZExjM3ZnbXRxM2xBNXo",
"config": { "name": "mobile-active-idmatch", "mode": "active", "strictness": "standard" },
"reference": { "required": true, "source": "upload", "uploaded": false },
"returnUrl": null, "returnAuto": false
}config is the full resolved profile, abbreviated above. sessionToken is shown once: it is stored hashed and cannot be retrieved later. captureUrl points at the hosted capture page with the session id and token in the URL fragment (#s=<sessionId>.<sessionToken>); the fragment never leaves the browser, so the token stays out of server logs and Referer headers. Hand it to the subject as a QR code or a link, and never move it into a query string.
422 with { "error": "Unknown liveness profile: <name>" }, { "error": "Validation failed", "issues": [...] }, or a returnUrl refusal (see below).
Return URL
returnUrl is where the capture page sends the subject when the capture is over. The page shows the outcome, then offers a button to your page; with returnAuto: true it counts down for a few seconds first and the subject can stop it. The button is the default on purpose: the capture page is the only place the subject is told what happened, and a redirect that fires on its own takes that away from the person it matters most to.
What travels back is the session id and nothing else. The subject lands on:
https://your-domain.example/onboarding/done?liveness_session_id=5b1f0c1e-8d3a-4f6b-9c2e-7a1d2e3f4a5bNo verdict, no status, no score. That parameter is a pointer, not a result: the page receiving it is unauthenticated, anyone holding the link can read and edit the query string, and a subject who failed could turn a verdict parameter into a pass before your server ever sees it. Read the outcome from your back end, with GET /liveness/sessions/:id or from the completion webhook. Treat the returning browser as an unauthenticated hint that the session is worth checking.
Registered origins. The URL is validated when the session is created, against the origins registered for the API key that created it: its return origins, or its allowed browser origins when no return origin is registered. This is what stops the hosted page being turned into an open redirect. Register the origin of your return page on the key before you use returnUrl: with neither list set, every value is refused.
The rules, on top of the registered-origin match:
| Rule | Refused example |
|---|---|
Absolute https URL | /onboarding/done, http://your-domain.example/done |
| No other scheme | javascript:, data:, file:, intent:// |
| No credentials in the URL | https://user:pass@your-domain.example/done |
| Exact origin match, scheme, host and port | https://sub.your-domain.example against a registered https://your-domain.example |
| At most 2048 characters |
Outside production only (a local checkout of the API, not api.verifiables.com), http://localhost, http://127.0.0.1 and http://[::1] are accepted without a registered origin, so you can try the flow on a laptop. In production there is no exemption: every return URL is https on a registered origin, loopback included.
Refusals are 422:
{ "error": "returnUrl origin is not registered for this API key",
"origin": "https://not-your-domain.example", "allowed": ["https://your-domain.example"] }GET /liveness/sessions
Your organisation's sessions, newest first. This is what the dashboard list shows, without the dashboard.
Authentication: API key (secret only). A publishable key answers 403. A publishable key is shipped to browsers by design, so it is only as secret as the page it sits in; enumerating every liveness session of an organisation, with its verdicts and its risk scores, is not something a browser key may do. Reading one session by an id you already hold stays open to both kinds of key.
curl "https://api.verifiables.com/liveness/sessions?status=completed&verdict=fail&limit=50" \
-H "Authorization: Bearer oat_your_secret_key"Every parameter is optional:
| Parameter | Type | Description |
|---|---|---|
status | string | created, challenged, processing, retake, completed, error or expired |
verdict | string | pass, review or fail |
profile | string | Profile name, matched exactly |
externalRef | string | Your own reference, matched exactly |
createdAfter | string | ISO 8601 with an offset. Inclusive: createdAt >= createdAfter |
createdBefore | string | ISO 8601 with an offset. Exclusive: createdAt < createdBefore, so consecutive windows tile without counting a session twice |
limit | number | 1 to 100, default 25 |
cursor | string | The nextCursor of the previous page |
An unknown enum value, a date that is not ISO 8601 or a cursor we did not mint is a 422 with issues: a typo in a filter never silently widens the list.
Response 200:
{
"sessions": [
{ "sessionId": "5b1f0c1e-8d3a-4f6b-9c2e-7a1d2e3f4a5b",
"status": "completed", "verdict": "fail", "riskScore": 72,
"profile": "mobile-active-qr", "externalRef": "order-4187",
"createdAt": "2026-09-18T10:00:00.000Z", "challengeIssuedAt": "2026-09-18T10:00:05.000Z",
"uploadedAt": "2026-09-18T10:00:40.000Z", "completedAt": "2026-09-18T10:00:55.000Z",
"retention": { "artifactsPurgeAt": "2026-09-18T11:00:00.000Z", "artifactsPurgedAt": null },
"failureReason": "challenge.timing: too_slow" }
],
"nextCursor": "MjAyNi0wOS0xOFQxMDowMDowMC4wMDBafDViMWYwYzFlLThkM2EtNGY2Yi05YzJlLTdhMWQyZTNmNGE1Yg"
}A list is not a report. A row carries the session id, the status, the verdict, the risk score, the profile name, your external ref, the four timestamps, the retention stamps and the failure reason. That is all of it. No checks, no report, no artifacts, no digests, no attestation, no face similarity, no forensic rating, nothing derived from the face and nothing about the media. Read GET /liveness/sessions/:id for any of that: it is the authoritative document, and it is the only place the detail lives.
Paging is a cursor, not a page number. Send the nextCursor of a page back as cursor to get the next one; nextCursor is null on the last page. The cursor names the last row you actually received, so sessions created between two of your requests cannot shift the window underneath you: no row is served twice and none is skipped. It is opaque. Do not parse it, do not build one, and do not keep one across a filter change.
GET /liveness/sessions/:id
The session document: the full report once status is completed or error, a partial document with progress while processing, the header fields alone before the capture. Every form carries attempts: { number, max, previous } (see retakes).
Authentication: API key (publishable or secret)
curl https://api.verifiables.com/liveness/sessions/5b1f0c1e-8d3a-4f6b-9c2e-7a1d2e3f4a5b \
-H "Authorization: Bearer oat_your_secret_key"Poll every 2 s until status is completed, error or expired. retake is not terminal: the capture could not be measured and the subject is doing it again on a fresh challenge, so keep polling (a failure is always completed with verdict: "fail"). Sessions of other organisations answer 404.
POST /liveness/sessions/:id/reference
Attaches the reference photo (an ID portrait, a previous selfie) the capture will be matched against. Allowed while the session is created or challenged; the subject can also upload it from the capture page.
Authentication: API key (publishable or secret)
curl -X POST https://api.verifiables.com/liveness/sessions/5b1f0c1e-8d3a-4f6b-9c2e-7a1d2e3f4a5b/reference \
-H "Authorization: Bearer oat_your_secret_key" \
-F "reference=@id-portrait.jpg"| Field | Type | Required | Description |
|---|---|---|---|
reference | File | Yes | JPEG, PNG or WebP, up to 5 MB, exactly one face |
Response 200:
{ "sessionId": "5b1f0c1e-8d3a-4f6b-9c2e-7a1d2e3f4a5b", "faceCheck": "checked",
"reference": { "sha256": "c41d…", "faces": 1, "faceScore": 0.94, "width": 600, "height": 800 } }A session whose profile takes its reference from the identity document (reference.source: "document") answers 409: its reference is the document's portrait and nothing else. The face is detected synchronously: 422 with { "error": "No face detected in the reference image", "faces": 0 } or "More than one face in the reference image". faceCheck is unavailable when the face engine is down, and the upload is then accepted without the check. 409 once the session is processing or terminal. Each upload adds a reference artifact and the latest one is used.
GET /liveness/sessions/:id/artifacts/:artifactId
Streams a retained artifact: a heatmap referenced by forensics.evidence[].heatmapArtifactId, an uploaded frame, or (secret key only) the raw video, the transcoded MP4 and the reference photo.
Authentication: API key; video, video_mp4 and reference artifacts require a secret key and answer 404 to a publishable one
curl https://api.verifiables.com/liveness/sessions/5b1f0c1e-8d3a-4f6b-9c2e-7a1d2e3f4a5b/artifacts/b5d4f6a7-8c9d-4e0f-1a2b-3c4d5e6f7a8b \
-H "Authorization: Bearer oat_your_secret_key" \
-o heatmap.pngThe response carries the artifact's Content-Type and Cache-Control: private, max-age=0. 404 for an artifact of another session, 410 once purged.
API-only analysis
POST /liveness/analyze
Runs the pipeline on media captured by your own application and returns the report in the same response. No session token and no challenge: the challenge stages are skipped, and the api-only-* profiles do not list them. The request blocks for the whole analysis, which takes tens of seconds with a video.
Authentication: API key (secret)
| Field | Type | Required | Description |
|---|---|---|---|
video | File | One of video or frames | WebM, MP4, MOV or MKV, up to 24 MB |
frames | File[] | One of video or frames | Up to 16 JPEG, PNG or WebP stills, 600 KB each, indexed by upload order |
reference | File | Profile dependent | JPEG, PNG or WebP, up to 5 MB; required when the profile's reference.required is true |
profile | string | No | Profile name, default mobile-quick-qr; prefer api-only-video or api-only-frames. A profile whose reference comes from the identity document is refused (422): it needs the capture page |
overrides | string | No | JSON-encoded profile overrides |
externalRef | string | No | Up to 100 characters |
metadata | string | No | JSON object, up to 1 KB |
meta | string | No | JSON capture metadata, every field optional. Provide frames[].tClientMs so frames can be bound to the video, and device or recording facts if you have them |
curl -X POST https://api.verifiables.com/liveness/analyze \
-H "Authorization: Bearer oat_your_secret_key" \
-F "profile=api-only-video" \
-F "externalRef=onboarding-2026-000482" \
-F "video=@capture.mp4;type=video/mp4" \
-F "frames=@frame-0.jpg;type=image/jpeg" \
-F "reference=@id-portrait.jpg;type=image/jpeg" \
-F 'meta={"recording":{"mimeType":"video/mp4","durationMs":6200},"frames":[{"index":0,"tClientMs":500},{"index":1,"tClientMs":3100}]}'Response 200: the full session document with status: "completed" (or error), verdict, riskScore and checks, exactly as returned by GET /liveness/sessions/:id. The session is persisted, so the report and its artifacts can be fetched again later with the integrator endpoints.
Frames-only analysis with api-only-frames needs no video and no ffmpeg on the server. With another profile and no video, media.probe and the video checks are skipped. Frames must not be mirrored: upload what the camera saw, not the mirrored preview.
400 when neither video nor frames is present, or when the profile requires a reference; 413 and 422 as in the limits above; 503 when the pipeline cannot start.
Completion webhook
Your back end should not have to poll GET /liveness/sessions/:id. Configure a webhook and the platform posts once, as soon as the session reaches completed, error or expired. A retake is not a terminal state and posts nothing: you hear from the session once, when its last attempt is decided.
One endpoint per organisation. Configuring it needs a secret key: the response hands back a signing secret, which a browser key must never see.
PUT /liveness/webhook
Creates or replaces the endpoint. Every write mints a new signing secret and returns it once. Store it; it is never shown again.
Authentication: API key (secret)
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | https URL on a public host, up to 2048 characters, no credentials in the URL |
active | boolean | No | Default true. false keeps the configuration and stops deliveries |
curl -X PUT https://api.verifiables.com/liveness/webhook \
-H "Authorization: Bearer oat_your_secret_key" \
-H "Content-Type: application/json" \
-d '{ "url": "https://hooks.your-domain.example/liveness" }'Response 201 (or 200 when it replaced an existing one):
{ "url": "https://hooks.your-domain.example/liveness", "active": true,
"secret": "whsec_6Qm9vSk1yZ2FsYVBwd0tZbnp2ZExjM3ZnbXRxM2xBNXo",
"createdAt": "2026-09-14T10:00:00.000Z", "updatedAt": "2026-09-14T10:00:00.000Z",
"secretSetAt": "2026-09-14T10:00:00.000Z" }422 when the URL is not an absolute https URL, carries credentials, uses another scheme, or points at a host the platform must not call from its own network: localhost, *.local, *.internal, and any IP literal in a loopback, private (RFC 1918), link-local, unique-local, unspecified or multicast range, in whatever notation. These are not the return URL rules: a return URL is where a browser is sent, a webhook URL is a request our servers make, so there is no loopback exemption here in any environment. To develop against a local receiver, expose it through an https tunnel and register the tunnel's hostname.
GET /liveness/webhook
The current configuration, without the secret. 404 when none is configured.
{ "url": "https://hooks.your-domain.example/liveness", "active": true,
"createdAt": "2026-09-14T10:00:00.000Z", "updatedAt": "2026-09-14T10:00:00.000Z",
"secretSetAt": "2026-09-14T10:00:00.000Z" }DELETE /liveness/webhook
Removes it. 204 on success, 404 when none is configured.
The delivery
One POST per session, fired when it becomes terminal. expired sessions and sessions the pipeline could not finish get one too, so a silent session is a session you never created.
{
"id": "7c2a9f14-3b5e-4d81-9a6c-2f0e8b1d4a37",
"type": "liveness.session.completed",
"timestamp": "2026-09-14T10:00:00.000Z",
"data": {
"sessionId": "5b1f0c1e-8d3a-4f6b-9c2e-7a1d2e3f4a5b",
"status": "completed",
"verdict": "pass",
"riskScore": 12,
"externalRef": "onboarding-2026-000482",
"createdAt": "2026-09-14T09:59:00.000Z",
"capturedAt": "2026-09-14T09:59:40.000Z",
"completedAt": "2026-09-14T10:00:00.000Z"
}
}type | When |
|---|---|
liveness.session.completed | The report is available; verdict and riskScore are set |
liveness.session.error | The pipeline failed or timed out; verdict is null |
liveness.session.expired | No capture arrived within the session's 10 minutes (counted again from a retake, so it also covers a subject who abandoned one). Fired by the expiry sweep, which runs every 5 minutes, so it lands up to 5 minutes after the deadline; a pipeline that overruns its 300 s budget is swept the same way and fires liveness.session.error |
The payload is deliberately thin. No report, no check list, no artifact id, no media, no face score, nothing derived from the subject's face. A webhook lands on an endpoint whose logging and retention are not ours, and biometric material does not belong there. Fetch the report with GET /liveness/sessions/:id when you need more than the verdict.
Headers and signature verification
| Header | Description |
|---|---|
X-Webhook-Id | Delivery id. Identical on every retry: use it as your idempotency key |
X-Webhook-Event | Same value as type |
X-Webhook-Timestamp | ISO 8601, when this attempt was sent. Fresh on every retry, so the age check below holds for retries too |
X-Webhook-Signature | v1=<hex>, the HMAC-SHA256 of <X-Webhook-Timestamp>.<raw body> |
X-Webhook-Attempt | 1 on the first try, up to 5 |
User-Agent | Always Verifiables-Liveness/1.0 |
The signature covers the header timestamp as well as the body, so a delivery captured on the wire cannot be replayed to you later under a fresh timestamp. The timestamp inside the body is a different value: it is when the delivery was created, it is part of the body and it never changes. Verify against the raw body, before any JSON parse and re-serialise.
import { createHmac, timingSafeEqual } from 'node:crypto'
const MAX_SKEW_MS = 5 * 60 * 1000
function verifyLivenessWebhook(secret: string, rawBody: Buffer, headers: Record<string, string>): boolean {
const timestamp = headers['x-webhook-timestamp']
const signature = headers['x-webhook-signature']
if (!timestamp || !signature?.startsWith('v1=')) return false
// Reject anything too old to be a live delivery.
const age = Date.now() - Date.parse(timestamp)
if (!Number.isFinite(age) || Math.abs(age) > MAX_SKEW_MS) return false
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody.toString()}`).digest('hex')
const presented = Buffer.from(signature.slice(3), 'hex')
const computed = Buffer.from(expected, 'hex')
return presented.length === computed.length && timingSafeEqual(presented, computed)
}
// Express: the raw body is required, so mount this before any JSON parser.
app.post('/liveness', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyLivenessWebhook(process.env.LIVENESS_WEBHOOK_SECRET!, req.body, req.headers as Record<string, string>)) {
return res.status(401).send('bad signature')
}
const { id, data } = JSON.parse(req.body.toString())
if (alreadyProcessed(id)) return res.status(200).send('ok') // idempotency key
handle(data)
res.status(200).send('ok')
})Monitoring webhooks use the same header names with a different signature: a bare hex digest over the body alone, with no v1= prefix and no timestamp inside the MAC. The prefix tells the two apart if one endpoint receives both.
Retries and delivery guarantees
Answer 2xx as soon as you have durably recorded the delivery. Any other status, a timeout over 10 seconds, or a connection failure schedules a retry.
Redirects are not followed. A 3xx answer is recorded as a failed attempt with its status and retried on the schedule below, whatever the Location. Register the final URL: the platform posts to the address you registered and to nothing else.
| Attempt | Sent |
|---|---|
| 1 | Immediately, as the session goes terminal |
| 2 | 1 minute later |
| 3 | 5 minutes after attempt 2 |
| 4 | 30 minutes after attempt 3 |
| 5 | 2 hours after attempt 4 |
After the fifth failure the delivery is abandoned. The session and its report stay available through GET /liveness/sessions/:id, which remains the authoritative read.
At most one delivery exists per session, so the webhook fires once. Retries of that one delivery repeat the same X-Webhook-Id and the same body byte for byte, with a fresh X-Webhook-Timestamp and therefore a fresh signature: the id and the body are what make it usable as an idempotency key, and the fresh timestamp is what lets the verification recipe above accept every attempt at its own send time. Treat a repeat as a duplicate, not as a second event. Each attempt is made by exactly one worker, so you never receive the same attempt number twice.
Subject endpoints
The hosted capture page uses these, under /liveness/public/sessions/:id, with the session token as a bearer:
Authorization: Bearer <sessionToken>They cover the capture handshake, the reference upload, the identity document upload and the media upload, and they end with a progress endpoint the page polls:
{ "status": "processing", "verdict": null, "progress": { "stage": "…", "percent": 72 }, "attestation": null,
"attempt": { "number": 1, "max": 3 }, "retake": null, "failure": null, "pollAfterMs": 1500 }status: "retake" comes with retake: { "reason": "images_unmeasurable" } and attempt set to the attempt about to start: the page tells the subject their images could not be analysed and starts again from GET /challenge, which draws a fresh script on a session in retake and moves it back to challenged. failure: { "reason": "images_unmeasurable" } is set only on a session that failed because its last attempt could not be measured either, so the page can say why without revealing anything else of the report.
GET /challenge serves, next to the script, the part of the resolved profile the capture needs (mode, capture, reference, strictness, thresholds) and profile.attestation: { required }, true only where the profile requires the attested capture, so the app can decline a session it cannot attest before the camera opens rather than recognise the profile by name. Once the report exists, the status endpoint adds attestation: { status, reason? }, the outcome of the attestation.device check alone (null before the report and on profiles without the check). No other check reaches the subject.
GET /challenge also names who asks, requester: { "name" } (the organisation that created the session), so the page can say it to the subject. On a profile whose reference comes from the identity document, it serves the consent that covers the document (its own consent.version), accepted on the page before the document is photographed, and profile.document: { "kind", "sides": { "front", "back" }, "complete" }, the sides accepted so far (null on other profiles). The page sends each side to POST /document (multipart document, the side cut out and squared, optionally frame, the camera image it was cut from, read by the forensic analysis, and meta with kind (id_card or passport), side and whether the photo was forced past the framing checks). The side is read at once: 422 with retake, reason (portrait_missing, mrz_unreadable, mrz_check_digits) and attemptsLeft asks for it again, and the third upload of a side is accepted whatever it holds. An OCR outage never asks the subject to retake. The capture upload is refused with 400 until the document is complete.
401 without a token, 404 for an unknown session or a wrong token (indistinguishable by design), 410 once the session expired. A session accepts one capture per attempt; the first accepted upload moves it to processing and any other upload before the next retake gets 409. POST /challenge/restart only reissues a script before an upload and answers 409 on a session in retake. The full report is available to the integrator alone, through GET /liveness/sessions/:id.
Attested capture
On the mobile-attested profile the capture runs in the Verifiables mobile application rather than in a browser, and the upload carries a device attestation. Two additions to the subject contract make it work, both private to the app:
POST /liveness/public/sessions/:id/attestation/noncehands the app a single-use nonce scoped to this session, valid for a few minutes. Asking again replaces it.POST /liveness/public/sessions/:id/attestation/enrolregisters the app's attestation key on a phone that has none registered yet (see below).- The capture request carries an optional
attestationfield next tometa: which platform, which enrolled key, the nonce, the app's digests of the exact bytes it is uploading, and the signature the device's hardware-backed app attestation produced over the nonce and those digests.
The platform recomputes the digests on the bytes it received, consumes the nonce before anything else (a failed verification spends it too), verifies the signature, and stores the outcome on the session before the analysis starts. The result is the attestation.device check in the report, plus an attestation section carrying the digests and the outcome as the intake recorded them, so the binding can be audited after the media itself has been purged; the check is the authoritative one of the two. Any failure is reported as a fail with its reason; on mobile-attested a capture without an attestation fails the session.
Enrolling the key
A capture assertion is verified against a key the platform already knows. A phone that has never received a credential in the wallet has no such key, so the app enrols it on first use of the liveness capture, with the same one-shot device attestation the wallet flow uses and into the same registry: afterwards the key asserts for both.
POST /liveness/public/sessions/:id/attestation/enrol
Authorization: Bearer <sessionToken>
Content-Type: application/json
{ "platform": "ios", "kind": "attestation", "keyId": "<base64url>", "attestation": "<base64url attestation object>", "nonce": "<nonce as issued>" }{ "sessionId": "…", "keyId": "<base64url>", "enrolled": true, "alreadyEnrolled": false }The attestation object is bound to a nonce of this session, obtained from the nonce endpoint like any other. The enrolment consumes that nonce, whatever its outcome, so the sequence on a first run is two nonces: one for the enrolment, then a fresh one for the capture. A nonce presented twice is refused as nonce_reused by whichever call presents it second. keyId is the SHA-256 of the key's public key; it is accepted in base64 or base64url, padded or not, and normalised to unpadded base64url before the registry is consulted, so the same key spelled differently is one key, and the response echoes the normalised form. A key the registry already holds (a lost response, a key the wallet enrolled) is answered 200 with alreadyEnrolled: true and nothing is written, so an enrolment can never reset the counter of a key in use. A refused attestation is a 422 with a reason (nonce_unknown, nonce_reused, nonce_expired, attestation_invalid with a detail, platform_unsupported); 409 and 410 apply as for the nonce; 503 when the platform cannot judge the attestation. Enrolment is throttled to 5 requests a minute per source address, as the other subject endpoints are.
What a pass proves is exactly this: that the unmodified application, on an intact device, produced these bytes after this nonce. It says nothing about what the camera saw, so it is a provenance fact about the upload, not a presentation attack detection, a proof of presence or a certified identity-proofing path. iOS is verified today. An Android claim is not verified yet, and the platform named in a claim is the client's own word, so it cannot waive a required check: on mobile-attested it fails the session with the reason platform_unsupported, exactly like a missing attestation; on a profile that does not require the check it is reported skipped: platform_unsupported. Never a pass.