LivFace

Overview

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.

Quick Start

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
}

Authentication

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.

Create Session

POST/v1/sessionsCreate a new liveness session

Request body

FieldTypeDefaultDescription
challenge_countinteger5Number of challenges (1–8)
webhook_urlstringURL 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-..."
}

Verify Result (Backend)

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"
}
StatusMeaning
pendingSession in progress
passedAll challenges passed — check score and id_verified
failedSpoof detected, ID mismatch, or score below threshold
expiredNot completed within 5 minutes
FieldTypeDescription
scorenumberLiveness confidence 0.0–1.0. Recommend ≥ 0.8.
liveness_verifiedbooleanAll challenges passed, no spoof detected
id_verifiedboolean | nullFace matched ID photo. null if no ID uploaded.
id_match_scorenumber | nullCosine similarity 0.0–1.0 (ArcFace 512-d)
id_document.issue_datestring | nullISO date the ID was issued, parsed from OCR
id_document.expiry_datestring | nullISO date the ID expires, parsed from OCR
id_document.validboolean | nullfalse 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 })

ID Document Verification

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
FieldTypeDescription
image_b64stringBase64 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.

FormatExample
DD/MM/YYYY15/03/2029
DD-MM-YYYY15-03-2029
DD MMM YYYY15 MAR 2029
YYYY-MM-DD2029-03-15
MM/YYYY03/2029
MRZ YYMMDD290315

Submit Frames (Advanced)

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
  }
}

Hosted Page

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
    }
  }}
/>

React Native SDK

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

PropTypeDefaultDescription
apiUrlstringrequiredBase URL of your API gateway
sessionIdstringrequiredSession ID from your backend
idImageB64stringBase64 ID card photo (full card, no data:… prefix)
frameIntervalMsnumber1000ms between frame captures
onCompletefunctionrequiredCalled when session ends (pass or fail)
onErrorfunctionCalled on unrecoverable errors
primaryColorstring#4ade80Ring fill and accent colour
backgroundColorstring#0a0a0aBackground before camera loads
renderRingfunctionReplace the progress ring
renderStatusCardfunctionReplace the instruction card
renderResultfunctionReplace the pass/fail overlay
childrenReactNodeRendered above all other layers

Flutter SDK

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

ParameterTypeDefaultDescription
apiUrlStringrequiredBase URL of your API gateway
sessionIdStringrequiredSession ID from your backend
idImageBytesList<int>?Raw bytes of the ID card photo
frameIntervalDuration1000 msInterval between frame captures
onCompleteFunctionrequiredCalled when session ends
onErrorFunction?Called on unrecoverable errors
primaryColorColorColor(0xFF4ade80)Ring fill and accent colour
backgroundColorColorColor(0xFF0a0a0a)Background before camera loads
ringBuilderFunction?Replace the progress ring
statusBuilderFunction?Replace the instruction card
resultBuilderFunction?Replace the result overlay
overlayWidget?Rendered above all other layers

Webhooks

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)
})

Challenge Types

7 types, 5 randomly selected per session. Detected via MediaPipe 468-point face mesh at 1 frame/second.

Challenge IDInstruction shown to userDetection method
BLINKBlink your eyesEye Aspect Ratio (EAR) below threshold
SMILESmileMouth width / face width ratio
TURN_LEFTTurn your head leftNose X offset from face centre
TURN_RIGHTTurn your head rightNose X offset from face centre
OPEN_MOUTHOpen your mouth wideMouth gap / face height ratio
LOOK_DOWNLook downNose Y position relative to face height
RAISE_EYEBROWSRaise your eyebrowsEyebrow-to-eye gap / face height

Plans & Limits

PlanDaily limitPriceFeatures
Free100 verifications$0 / moBasic liveness, community support
Growth1,000 verifications$29 / moID verification + date validation, webhooks, email support
Scale10,000 verifications$79 / moAll Growth + priority support + SLA
High-Volume50,000 verifications$199 / moAll Scale + dedicated support + Face ID database

Errors

All errors return { "error": "..." }.

StatusMeaning
400Missing or invalid field
401Missing or invalid API key
402Daily limit reached — upgrade your plan
404Session ID not found
409Session already completed
410Session expired (5-minute TTL)
422No face detected in ID photo — use a flat, well-lit full-card photo
422ID document has expired — expiry_date and id_expired: true in body
429Rate limit — 30 frames / 10s per session
502Detection engine unavailable — retry
LivFace
DashboardAPI KeysSign up