Quick Start
Liveness is a challenge-response biometric API. Your backend creates a session, your frontend (or a hosted page) runs the camera checks, and you receive the result via webhook or direct API response.
const res = await fetch('https://api.liveness.io/v1/sessions', {
method: 'POST',
headers: {
'X-API-Key': process.env.LIVENESS_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ challenge_count: 5 }),
})
const { session_id, hosted_url } = await res.json()
// → send session_id / hosted_url to the clientimport { LivenessClient } from '@liveness/sdk'
const client = new LivenessClient({ apiUrl: 'https://api.liveness.io' })
const result = await client.run(sessionId, videoElement, apiKey)
// result: { passed: true, score: 0.968, sessionId: '...' }Authentication
All API requests require an X-API-Key header. Generate keys from the Dashboard. Keys are prefixed lv_ and are shown only once on creation.
curl https://api.liveness.io/v1/sessions \
-H "X-API-Key: lv_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{"challenge_count": 5}'Keep your API key server-side
Never embed your API key in frontend JavaScript. Create sessions from your backend and pass only the session_id (or hosted_url) to the client.
Create Session
Creates a new session with a random set of challenges. Requires a valid API key. Sessions expire after 5 minutes.
Request body
| Field | Type | Default | Description |
|---|---|---|---|
| challenge_count | integer | 5 | Number of challenges (1–8) |
Response
{
"session_id": "a1b2c3d4-...",
"challenges": ["BLINK", "SMILE", "TURN_LEFT", "OPEN_MOUTH", "RAISE_EYEBROWS"],
"current_challenge": "BLINK",
"challenge_index": 0,
"total_challenges": 5,
"expires_in_seconds": 300,
"hosted_url": "https://app.liveness.io/check?s=a1b2c3d4-..."
}Verify Frame
Submit a base64-encoded JPEG frame for the current challenge. The frame is analyzed by the MediaPipe face mesh engine. Call this on a timer (every ~800ms) while the camera is running.
Request body
| Field | Type | Description |
|---|---|---|
| frame_b64 | string | Base64-encoded JPEG (no data URI prefix). Max 4MB. |
Response — challenge in progress
{
"challenge_passed": false,
"face_detected": true,
"session_status": "pending",
"current_challenge": "BLINK",
"confidence": 0.12,
"message": "Challenge not passed yet — keep trying"
}Response — challenge passed, more remain
{
"challenge_passed": true,
"session_status": "pending",
"next_challenge": "SMILE",
"challenge_index": 1,
"total_challenges": 5,
"message": "Great! Now: SMILE"
}Response — session complete
{
"challenge_passed": true,
"session_status": "passed",
"score": 0.968,
"liveness_verified": true,
"message": "Liveness verified successfully"
}Response — spoof detected
{
"challenge_passed": false,
"session_status": "failed",
"reason": "spoof_detected",
"message": "Liveness check failed — please use a live camera"
}Hosted Page (Mobile & WebView)
The simplest way to integrate on mobile. Create a session server-side, open thehosted_url in a browser or WebView. No SDK needed on the client.
Open hosted_url in a new tab or redirect. The page closes or shows a result when done.
Embed in a WebView. Listen for postMessage to receive the result in-app.
import { WebView } from 'react-native-webview'
import { useRef } from 'react'
export default function LivenessScreen({ hostedUrl, onResult }) {
const ref = useRef(null)
return (
<WebView
ref={ref}
source={{ uri: hostedUrl }}
onMessage={(e) => {
const { type, passed, score, session_id } = JSON.parse(e.nativeEvent.data)
if (type === 'liveness_result') {
onResult({ passed, score, session_id })
}
}}
/>
)
}WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('LivenessResult', onMessageReceived: (msg) {
final data = jsonDecode(msg.message);
// data['passed'], data['score'], data['session_id']
})
..loadRequest(Uri.parse(hostedUrl));The hosted page posts the message to both window.parent (iframe) andwindow (WebView). The message shape is:
{
"type": "liveness_result",
"passed": true,
"score": 0.968,
"session_id": "a1b2c3d4-..."
}Webhooks
Configure a callback URL in your Dashboard under Integrations. We POST to this URL whenever a session completes (passed or failed).
Payload
{
"event": "session.completed",
"session_id": "a1b2c3d4-...",
"passed": true,
"score": 0.968,
"reason": null,
"timestamp": "2026-06-01T12:00:00.000Z"
}Verifying delivery
Your endpoint must return a 2xx status. We retry once after 5 seconds on timeout or non-2xx. Webhook calls timeout after 5 seconds.
app.post('/webhooks/liveness', express.json(), (req, res) => {
const { event, session_id, passed, score } = req.body
if (event === 'session.completed') {
if (passed) {
// unlock the user's account, continue onboarding flow, etc.
console.log(`Session ${session_id} passed with score ${score}`)
} else {
console.log(`Session ${session_id} failed`)
}
}
res.sendStatus(200)
})Browser SDK
The browser SDK wraps camera access, frame capture, and the verify API into a single run() call. Use it when you want the camera UI embedded directly in your own page.
Installation
npm install @liveness/sdkUsage
import { LivenessClient } from '@liveness/sdk'
const client = new LivenessClient({
apiUrl: 'https://api.liveness.io',
})
// videoElement is an HTMLVideoElement already in the DOM
const result = await client.run(sessionId, videoElement, apiKey)
// result shape:
// { passed: boolean, score: number, sessionId: string }
if (result.passed) {
console.log('Liveness score:', result.score) // 0.0 – 1.0
}LivenessClient options
| Option | Type | Description |
|---|---|---|
| apiUrl | string | Base URL of the Liveness API |
| frameInterval | number | ms between frame captures (default: 800) |
| jpegQuality | number | JPEG quality 0–1 (default: 0.8) |
React component
Prefer a drop-in React component? Import LivenessWidget from your local components or use the hosted page instead.
import LivenessWidget from '@/components/LivenessWidget'
<LivenessWidget
apiKey={sessionApiKey}
challengeCount={5}
onComplete={({ passed, score }) => {
console.log('done', passed, score)
}}
/>Challenge Types
7 challenge types are available. 5 are chosen randomly per session so the sequence is unpredictable. Each is detected via MediaPipe 468-landmark face mesh.
| ID | Label | Detection method |
|---|---|---|
| BLINK | Blink your eyes | Eye Aspect Ratio (EAR) below threshold |
| SMILE | Smile | Mouth width / face width ratio |
| TURN_LEFT | Turn head left | Nose X offset from face center |
| TURN_RIGHT | Turn head right | Nose X offset from face center |
| OPEN_MOUTH | Open mouth wide | Mouth gap / face height ratio |
| LOOK_DOWN | Look down | Nose Y position relative to face |
| RAISE_EYEBROWS | Raise eyebrows | Eyebrow-to-eye gap / face height |
Spoof detection
Every frame is also checked for Laplacian variance. A flat-printed photo or screen replay has very low texture variance and is immediately rejected with reason: "spoof_detected", failing the session regardless of challenge state.
Webhook Signing
Every webhook request includes an X-Liveness-Signature header so your backend can confirm the request came from us and not a third party.
How it works
Request headers
POST /webhooks/liveness HTTP/1.1
Content-Type: application/json
X-Liveness-Signature: sha256=a3f1c8...The value is always sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body. Compute it against the raw bytes before any JSON parsing — parsing and re-serialising can change whitespace and break the check.
Verification — Node.js
const crypto = require('crypto')
function verifyWebhook(rawBody, signatureHeader, secret) {
const expected = 'sha256=' +
crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
// timingSafeEqual prevents timing attacks
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
)
}
// Use express.raw() so req.body is a Buffer (unparsed)
app.post('/webhooks/liveness', express.raw({ type: '*/*' }), (req, res) => {
const sig = req.headers['x-liveness-signature']
if (!sig || !verifyWebhook(req.body, sig, process.env.WEBHOOK_SECRET)) {
return res.sendStatus(401)
}
const { event, passed, score, session_id } = JSON.parse(req.body)
if (event === 'session.completed' && passed) {
// unlock account, continue onboarding, etc.
}
res.sendStatus(200)
})Verification — Python
import hmac, hashlib, os
def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = 'sha256=' + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)
# FastAPI
@app.post("/webhooks/liveness")
async def webhook(request: Request):
raw = await request.body()
sig = request.headers.get("x-liveness-signature", "")
if not verify_webhook(raw, sig, os.environ["WEBHOOK_SECRET"]):
raise HTTPException(status_code=401)
payload = json.loads(raw)
# handle payload …
return {"ok": True}Rotate your secret safely
If a secret is compromised, click Rotate secret in the Dashboard. Update your environment variable before the next webhook fires. Old and new secrets are never active at the same time.
Errors
All errors return a JSON body with an error string and an appropriate HTTP status code.
| Status | Meaning |
|---|---|
| 400 Bad Request | Missing or invalid field in the request body |
| 401 Unauthorized | Missing or invalid API key |
| 404 Not Found | Session ID does not exist |
| 409 Conflict | Session is no longer pending (already passed, failed, or expired) |
| 410 Gone | Session has expired (5-minute TTL exceeded) |
| 429 Too Many Requests | Rate limit hit — 30 frame submissions per 10 seconds |
| 502 Bad Gateway | Detection engine temporarily unavailable |
{ "error": "Session is already 'passed'" }