LivFace is a challenge-response biometric API. Your backend creates a session, the mobile SDK or hosted page runs camera challenges on the user's device, and you verify the result server-side using your API key.
01
Create session
Backend calls POST /v1/sessions → gets session_id.
02
Send to app
Pass session_id to mobile app or open hosted_url.
03
User completes
SDK captures frames every second across challenges.
04
Verify result
Call GET /v1/sessions/:id to confirm the outcome.
1. Backend — create session (Node.js)
const res = await fetch('https://api.livface.com/v1/sessions', {
method: 'POST',
headers: {
'x-api-key': process.env.LIVEFACE_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ challenge_count: 5 }),
})
const { session_id } = await res.json()
2. Mobile — run liveness (React Native / Expo)
import { LivenessView } from 'liveface'
<LivenessView
apiUrl="https://api.livface.com"
sessionId={session_id}
onComplete={(result) => {
if (result.passed) navigation.replace('Success')
}}
/>
3. Backend — verify result
const { status, score, id_document } = await fetch(
`https://api.livface.com/v1/sessions/${sessionId}`,
{ headers: { 'x-api-key': process.env.LIVEFACE_API_KEY } }
).then(r => r.json())
if (status === 'passed' && score > 0.8) {
// mark user verified
}
All server-side requests require an x-api-key header. Generate keys from Dashboard → API Keys.
curl https://api.livface.com/v1/sessions \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"challenge_count": 5}'
Keep your API key on the server
Never embed your API key in a mobile app or browser JavaScript. The mobile SDK uses session_id as its credential — no API key needed on the device.
POST/v1/sessionsCreate a new liveness session
Request body
| Field | Type | Default | Description |
|---|
challenge_count | integer | 5 | Number of challenges (1–8) |
webhook_url | string | — | URL to POST the result when session completes |
Response
{
"session_id": "3f7a1b2c-e4d5-...",
"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://livface.com/check?s=3f7a1b2c-..."
}
GET/v1/sessions/:idGet status and result — requires API key
Always verify server-side after the SDK reports a result. Never trust what the mobile app sends.
{
"session_id": "3f7a1b2c-...",
"status": "passed",
"score": 0.97,
"liveness_verified": true,
"id_verified": true,
"id_match_score": 0.91,
"id_document": {
"issue_date": "2021-03-10",
"expiry_date": "2029-03-10",
"valid": true
},
"completed_at": "2026-06-25T15:00:00.000Z"
}
| Status | Meaning |
|---|
| pending | Session in progress |
| passed | All challenges passed — check score and id_verified |
| failed | Spoof detected, ID mismatch, or score below threshold |
| expired | Not completed within 5 minutes |
| Field | Type | Description |
|---|
score | number | Liveness confidence 0.0–1.0. Recommend ≥ 0.8. |
liveness_verified | boolean | All challenges passed, no spoof detected |
id_verified | boolean | null | Face matched ID photo. null if no ID uploaded. |
id_match_score | number | null | Cosine similarity 0.0–1.0 (ArcFace 512-d) |
id_document.issue_date | string | null | ISO date the ID was issued, parsed from OCR |
id_document.expiry_date | string | null | ISO date the ID expires, parsed from OCR |
id_document.valid | boolean | null | false if expiry_date is in the past |
Node.js — verify and unlock user
const { status, score, id_verified, id_document } = await fetch(
`https://api.livface.com/v1/sessions/${sessionId}`,
{ headers: { 'x-api-key': process.env.LIVEFACE_API_KEY } }
).then(r => r.json())
if (status !== 'passed') return res.status(400).json({ error: 'Verification failed' })
if (!id_document?.valid) return res.status(400).json({ error: 'ID document expired' })
await db.users.update({ id: userId }, { kyc_verified: true })
Upload an ID photo before challenges start. LivFace extracts the face, reads the document dates via OCR, validates the expiry, and compares the ID face to the live capture using ArcFace.
Optional feature
Skip this step for liveness-only checks. When skipped, id_verified, id_match_score, and id_document all return null.
POST/v1/sessions/:id/upload-id-publicUpload ID — session_id is credential, no API key
| Field | Type | Description |
|---|
image_b64 | string | Base64 JPEG or PNG of the full ID card. No data:… prefix. Max 4 MB. |
What happens on upload
01
Face detection
Finds the photo on the card even when it occupies only 10–20% of the image.
02
Face embedding
ArcFace (512-d) embedding stored for match at the end of liveness.
03
OCR scan
Tesseract reads the full document. Issue date and expiry date extracted.
04
Expiry check
If the expiry date is in the past the upload is rejected immediately.
Success response
{
"ok": true,
"message": "ID photo accepted",
"id_document": {
"issue_date": "2021-03-10",
"expiry_date": "2029-03-10",
"valid": true
}
}
Expired ID response
HTTP 422
{
"error": "ID document has expired",
"expiry_date": "2024-01-15",
"id_expired": true
}
Sending the full card photo
Take a photo of the entire ID card lying flat — do not crop or zoom into the face. The engine uses a full-range face detector that locates the face regardless of how small it is on the card.
Date formats supported
OCR parses dates in any of these formats. MRZ zones (TD1/TD3) are also parsed as a fallback.
| Format | Example |
|---|
| DD/MM/YYYY | 15/03/2029 |
| DD-MM-YYYY | 15-03-2029 |
| DD MMM YYYY | 15 MAR 2029 |
| YYYY-MM-DD | 2029-03-15 |
| MM/YYYY | 03/2029 |
| MRZ YYMMDD | 290315 |
For custom camera integrations, submit frames directly instead of using the SDK.
POST/v1/sessions/:id/verify-publicSubmit a frame — no API key needed
Request
{ "frame_b64": "base64-encoded-jpeg..." }
Response — challenge in progress
{
"challenge_passed": false,
"face_detected": true,
"session_status": "pending",
"current_challenge": "BLINK",
"confidence": 0.42
}
Response — session complete
{
"challenge_passed": true,
"session_status": "passed",
"score": 0.97,
"liveness_verified": true,
"id_verified": true,
"id_match_score": 0.91,
"id_document": {
"issue_date": "2021-03-10",
"expiry_date": "2029-03-10",
"valid": true
}
}
Open hosted_url in a WebView. No SDK needed — the page handles camera, challenges, and posts the result.
React Native — WebView
import { WebView } from 'react-native-webview'
<WebView
source={{ uri: hostedUrl }}
onMessage={(e) => {
const { type, passed, session_id } = JSON.parse(e.nativeEvent.data)
if (type === 'liveness_result') {
// verify server-side with GET /v1/sessions/:id
}
}}
/>
The liveface npm package for Expo apps. Current version: v1.2.0
Installation
npx expo install expo-camera
npm install liveface
app.json — camera permission
{
"expo": {
"plugins": [
["expo-camera", { "cameraPermission": "Used for identity verification." }]
]
}
}
Drop-in component
VerifyScreen.tsx
import { LivenessView } from 'liveface'
export function VerifyScreen({ sessionId }: { sessionId: string }) {
return (
<LivenessView
apiUrl="https://api.livface.com"
sessionId={sessionId}
onComplete={(result) => {
if (result.passed) navigation.replace('Success')
else console.log('Failed:', result.reason)
}}
/>
)
}
With ID document verification
<LivenessView
apiUrl="https://api.livface.com"
sessionId={sessionId}
idImageB64={base64IdPhoto} // full card photo, no data:... prefix
onComplete={(result) => {
console.log(result.idVerified) // face matched
console.log(result.idMatchScore) // 0.0 – 1.0
console.log(result.idDocument?.expiryDate) // '2029-03-10'
console.log(result.idDocument?.valid) // true
}}
/>
Theming
Override the two colour tokens to match your brand. All UI elements derive from primaryColor.
<LivenessView
apiUrl="https://api.livface.com"
sessionId={sessionId}
primaryColor="#6366f1" // ring, spinner, step dots (default: #4ade80)
backgroundColor="#0f0f0f" // background before camera loads
onComplete={handleComplete}
/>
Custom UI slots
Replace any section of the built-in UI independently. The rest stays as-is.
<LivenessView
apiUrl="https://api.livface.com"
sessionId={sessionId}
onComplete={handleComplete}
// Replace the progress ring
renderRing={({ filledRatio, faceDetected }) => (
<MyRing progress={filledRatio} active={faceDetected} />
)}
// Replace the bottom instruction card
renderStatusCard={({ status, progressCount, progressTotal }) => (
<MyStatusCard label={status} step={progressCount} total={progressTotal} />
)}
// Replace the pass/fail overlay — call onRetry() to restart
renderResult={({ phase, result, onRetry }) => (
<MyResultScreen passed={phase === 'passed'} onRetry={onRetry} />
)}
// Extra layer above everything (branding, close button, etc.)
>
<SafeAreaView style={StyleSheet.absoluteFill} pointerEvents="box-none">
<TouchableOpacity style={styles.close} onPress={navigation.goBack}>
<Text>✕</Text>
</TouchableOpacity>
</SafeAreaView>
</LivenessView>
Fully headless — custom camera UI
Use useLiveness when you want complete control over every pixel.
import { CameraView } from 'expo-camera'
import { useLiveness } from 'liveface'
export function CustomVerifyScreen({ sessionId }: { sessionId: string }) {
const {
cameraRef,
phase, // 'idle' | 'uploading_id' | 'running' | 'passed' | 'failed'
status, // current instruction string
progressCount, // challenges completed
progressTotal, // total challenges
faceDetected,
result,
start,
reset,
} = useLiveness({
apiUrl: 'https://api.livface.com',
sessionId,
onComplete: (r) => console.log(r),
})
return (
<View style={{ flex: 1 }}>
<CameraView ref={cameraRef} style={StyleSheet.absoluteFill} facing="front" />
<Text style={styles.label}>{status}</Text>
{phase === 'idle' && <Button title="Start" onPress={start} />}
{phase === 'failed' && <Button title="Try again" onPress={reset} />}
</View>
)
}
LivenessResult type
interface LivenessResult {
passed: boolean
sessionId: string
score?: number
livenessVerified?: boolean
idVerified?: boolean
idMatchScore?: number
reason?: string // 'spoof_detected' | 'id_mismatch' | 'session_expired'
}
LivenessView props
| Prop | Type | Default | Description |
|---|
apiUrl | string | required | Base URL of your API gateway |
sessionId | string | required | Session ID from your backend |
idImageB64 | string | — | Base64 ID card photo (full card, no data:… prefix) |
frameIntervalMs | number | 1000 | ms between frame captures |
onComplete | function | required | Called when session ends (pass or fail) |
onError | function | — | Called on unrecoverable errors |
primaryColor | string | #4ade80 | Ring fill and accent colour |
backgroundColor | string | #0a0a0a | Background before camera loads |
renderRing | function | — | Replace the progress ring |
renderStatusCard | function | — | Replace the instruction card |
renderResult | function | — | Replace the pass/fail overlay |
children | ReactNode | — | Rendered above all other layers |
The livface Flutter package. Same feature set as the React Native SDK — drop-in widget, theming, builder slots, and headless client. Current version: v1.0.0
Installation
pubspec.yaml
dependencies:
livface: ^1.0.0
Platform permissions
# Android — AndroidManifest.xml
<uses-permission android:name="android.permission.CAMERA" />
# iOS — Info.plist
<key>NSCameraUsageDescription</key>
<string>Used for identity verification.</string>
Drop-in widget
verify_screen.dart
import 'package:livface/livface.dart';
class VerifyScreen extends StatelessWidget {
final String sessionId;
const VerifyScreen({required this.sessionId, super.key});
@override
Widget build(BuildContext context) {
return LivenessView(
apiUrl: 'https://api.livface.com',
sessionId: sessionId,
onComplete: (result) {
if (result.passed) {
Navigator.pushReplacementNamed(context, '/success');
}
},
);
}
}
With ID document verification
LivenessView(
apiUrl: 'https://api.livface.com',
sessionId: sessionId,
idImageBytes: await File(idPhotoPath).readAsBytes(), // full card photo
onComplete: (result) {
print('Face matched: ${result.idVerified}');
print('Match score: ${result.idMatchScore}');
},
)
Theming
LivenessView(
apiUrl: 'https://api.livface.com',
sessionId: sessionId,
primaryColor: const Color(0xFF6366F1), // ring and accent
backgroundColor: const Color(0xFF0F0F0F),
onComplete: handleComplete,
)
Custom UI slots
LivenessView(
apiUrl: 'https://api.livface.com',
sessionId: sessionId,
onComplete: handleComplete,
// Replace the progress ring
ringBuilder: (context, filledRatio, faceDetected) =>
MyRing(progress: filledRatio, active: faceDetected),
// Replace the bottom instruction card
statusBuilder: (context, status, done, total) =>
MyStatusCard(label: status, done: done, total: total),
// Replace the result overlay (onRetry is null when passed == true)
resultBuilder: (context, result, onRetry) =>
MyResultScreen(result: result, onRetry: onRetry),
// Extra layer above everything
overlay: Align(
alignment: Alignment.topRight,
child: SafeArea(
child: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
),
),
)
Fully headless
import 'package:livface/livface.dart';
import 'package:camera/camera.dart';
final client = LivenessClient('https://api.livface.com');
final result = await client.run(
() async {
final xfile = await cameraController.takePicture();
return File(xfile.path).readAsBytes();
},
RunOptions(
sessionId: sessionId,
onStatus: (msg) => setState(() => _label = msg),
onProgress: (done, total, _) => setState(() => _progress = done / total),
),
);
LivenessView parameters
| Parameter | Type | Default | Description |
|---|
apiUrl | String | required | Base URL of your API gateway |
sessionId | String | required | Session ID from your backend |
idImageBytes | List<int>? | — | Raw bytes of the ID card photo |
frameInterval | Duration | 1000 ms | Interval between frame captures |
onComplete | Function | required | Called when session ends |
onError | Function? | — | Called on unrecoverable errors |
primaryColor | Color | Color(0xFF4ade80) | Ring fill and accent colour |
backgroundColor | Color | Color(0xFF0a0a0a) | Background before camera loads |
ringBuilder | Function? | — | Replace the progress ring |
statusBuilder | Function? | — | Replace the instruction card |
resultBuilder | Function? | — | Replace the result overlay |
overlay | Widget? | — | Rendered above all other layers |
Pass a webhook_url when creating a session. LivFace POSTs the result on completion.
Payload
{
"event": "session.completed",
"session_id": "3f7a1b2c-...",
"passed": true,
"score": 0.97,
"liveness_verified": true,
"id_verified": true,
"id_match_score": 0.91,
"id_document": {
"issue_date": "2021-03-10",
"expiry_date": "2029-03-10",
"valid": true
},
"timestamp": "2026-06-25T12:00:00.000Z"
}
Express — verify signature
app.post('/webhooks/liveface', express.raw({ type: '*/*' }), (req, res) => {
const sig = req.headers['x-liveness-signature']
const expected = 'sha256=' + require('crypto')
.createHmac('sha256', process.env.LIVEFACE_WEBHOOK_SECRET)
.update(req.body).digest('hex')
if (!require('crypto').timingSafeEqual(Buffer.from(sig), Buffer.from(expected)))
return res.sendStatus(401)
const { event, passed, id_document } = JSON.parse(req.body)
if (event === 'session.completed' && passed) {
// id_document.valid confirms the ID hasn't expired
}
res.sendStatus(200)
})
7 types, 5 randomly selected per session. Detected via MediaPipe 468-point face mesh at 1 frame/second.
| Challenge ID | Instruction shown to user | Detection method |
|---|
BLINK | Blink your eyes | Eye Aspect Ratio (EAR) below threshold |
SMILE | Smile | Mouth width / face width ratio |
TURN_LEFT | Turn your head left | Nose X offset from face centre |
TURN_RIGHT | Turn your head right | Nose X offset from face centre |
OPEN_MOUTH | Open your mouth wide | Mouth gap / face height ratio |
LOOK_DOWN | Look down | Nose Y position relative to face height |
RAISE_EYEBROWS | Raise your eyebrows | Eyebrow-to-eye gap / face height |
| Plan | Daily limit | Price | Features |
|---|
| Free | 100 verifications | $0 / mo | Basic liveness, community support |
| Growth | 1,000 verifications | $29 / mo | ID verification + date validation, webhooks, email support |
| Scale | 10,000 verifications | $79 / mo | All Growth + priority support + SLA |
| High-Volume | 50,000 verifications | $199 / mo | All Scale + dedicated support + Face ID database |
All errors return { "error": "..." }.
| Status | Meaning |
|---|
| 400 | Missing or invalid field |
| 401 | Missing or invalid API key |
| 402 | Daily limit reached — upgrade your plan |
| 404 | Session ID not found |
| 409 | Session already completed |
| 410 | Session expired (5-minute TTL) |
| 422 | No face detected in ID photo — use a flat, well-lit full-card photo |
| 422 | ID document has expired — expiry_date and id_expired: true in body |
| 429 | Rate limit — 30 frames / 10s per session |
| 502 | Detection engine unavailable — retry |