Facededup
Docs · API v1
● Facededup Identity

Developer documentation

Face liveness + Nigerian identity verification (NIN/BVN, MRZ documents, 1:1 face match). Integrate in minutes with the drop-in mobile SDK, or call the REST API directly. Base URL:

https://facededup.ai
⚡ Personalise these docs

Drop in your base URL and license key — every code snippet and Copy button on this page updates to use them, so you can paste runnable code. Stored only in your browser.

Overview

Facededup is a face-liveness + identity-verification platform built for Nigeria. Each capability is a product you can run on its own or chain together. Under the hood a verification session runs four short steps, then optionally resolves identity:

  1. Consent — record the subject's explicit consent (NDPA lawful basis).
  2. Request — open a verification request bound to that consent.
  3. Challenge — get a randomized active-liveness challenge (e.g. turn left → look up → turn right).
  4. Verify — submit the captured frames; receive a signed live / referred / not_live result.
  5. Identity — after liveness passes, 1:1-match the live selfie to a NIN/BVN or document photo.

The mobile SDK runs all of this inside a managed WebView and hands you a typed result — you don't touch the individual endpoints. The REST reference below is for server-to-server or custom clients.

Liveness is decided server-side. On-device face tracking is only for capture guidance — the live/not-live verdict comes from the server's multi-frame motion + anti-spoof engine, so a client can't fake a pass. If the on-device engine is unavailable (e.g. some mobile WebViews), the SDK falls back to a guided timed capture and the server still makes the call — the flow never blocks.

Quickstart

From zero to a verified user in five steps — the same path whether you ship the mobile SDK or call the REST API.

  1. Get access. Ask us for your Facededup host + demo password (or, in production, a per-tenant API key). See Authentication.
  2. Choose a product. Liveness only? Liveness + NIN/BVN? Document + face? Pick from Products below — each one tells you the method and endpoint to use.
  3. Choose an integration. Drop-in Android / iOS SDK, a web embed, or the REST API for custom capture / server-to-server.
  4. Run your first verification. Launch the SDK (or POST consent → request → challenge → verify) and read the signed live / referred / not_live result. Test it offline too — see Offline mode.
  5. Go live. Swap the demo password for an API key, review decisions in the tenant console, and tune the security switchboard for your risk appetite.
IntegrationBest forWhat you do
Mobile SDK (WebView)iOS / Android apps Add the dependency, call verify.launch(...), read the result. No camera/permission/HTTP plumbing. Engine bundled → works offline.
Web embedWeb appsOpen the hosted /demo/ flow; receive the result via a JS bridge / redirect.
REST APIServers, custom captureDrive consent → request → challenge → verify yourself.

Products

Six building blocks. Mix them to match your assurance level — from a quick liveness check to a full Biometric KYC with document, address and dedup.

Face Liveness

Active-liveness selfie check — randomized head-motion challenges (turn, look up/down) prove a real, present human. Returns a signed live / referred / not_live.
Offline capture method: face_liveness · REST →

Biometric KYC

Liveness + a 1:1 match of the live face to the authority photo behind a NIN or BVN, returning the verified profile (name, DOB). NCC §4 match band.
Needs network /v1/identity/verify · REST →

Identity Lookup

Face-only 1:N — resolve a live selfie to an identity with no number typed, then confirm with a 1:1 compare. Good for returning-user sign-in.
Needs network /v1/identity/search · REST →

Document Verification

Validate an ICAO-9303 MRZ (passport, NIN slip, driver's licence) and 1:1-match the live selfie to the document photo. No registry lookup needed.
Offline capture /v1/document/verify · REST →

Address Verification

Geocode a postal address and confirm the subject's captured device GPS is on-site (OkHi-style presence proof). Distance-banded match / refer / no_match.
Needs network /v1/address/verify · REST →

Face Enrollment & Dedup

Enroll a live selfie to a gallery and get an enrollment id for later 1:N search and duplicate-face detection across your user base.
Offline capture /v1/face/enroll · REST →
Which do I need? Onboarding a new user against the national registry → Biometric KYC. Verifying a physical ID card → Document Verification. Just proving "real human, here, now" → Face Liveness (+ Address for proof of residence). Returning user → Identity Lookup.

Authentication

Production: a per-tenant license key (fdk_…). Send it as the X-License-Key header (or ?license= on a WebView URL). It authenticates you and fixes your tenant — a key can't masquerade as another tenant, and unknown/inactive keys are rejected, so the open-source SDK can't be used without a key. The shared demo password (cookie for browsers, HTTP Basic for native) still gates the public browser demo.

CallerHow
SDK / server (prod)X-License-Key: fdk_… — your tenant key; the SDK sends it for you.
Browser demoLogin page sets an HMAC cookie (sw_demo) from the shared demo password.
curl -u :YOUR_PASSWORD https://facededup.ai/v1/security
Open endpoints: /healthz and this /docs page need no auth. Everything else (/v1/*, /demo, /console) is gated.

⚡ API Playground live

Run real, authenticated calls against the API right here — no Postman, no curl. Paste your license key once, then click a step. The flow auto-threads IDs (consent_id → request_id), so you can watch a full liveness handshake happen and inspect every response.

Same-origin only. The live calls work on this page (https://facededup.ai/docs); a downloaded copy can't call the API (browser CORS). Calls run from your browser with your key — nothing is sent anywhere else.
captured: — run a step to capture consent_id / request_id / session_id / nonce —
Response will appear here.

  

Android SDK

A drop-in module — ng.facededup:facededup — that runs the full hosted flow in a managed WebView and returns a typed FacededupResult via ActivityResult. It bundles the MediaPipe engine, so the face check works offline. minSdk 21.

Native SDK (beta) — facededup-live 2.0.0-beta.60. A Compose-native alternative (CameraX + ML Kit, no WebView) with the full layered anti-replay stack — active illumination, continuous passive PAD, and continuity tracking (anti swipe/collage). Captures at 720p for crisp frames, and always returns within ~90 s (watchdog) so your callback never hangs. minSdk 21. Two ways in — pick one:

A · Gradle one-line (simplest) — auto-resolves dependencies.

// settings.gradle.kts → dependencyResolutionManagement.repositories
maven { url = uri("https://swiftend-assets-348761024048.s3.eu-west-2.amazonaws.com/m2") }
// app/build.gradle.kts
implementation("ng.facededup:facededup-live:2.0.0-beta.60")

B · Drag-in the AAR file — download, drop in app/libs/, add the runtime deps (an AAR via files() carries none):

// 1) download → app/libs/facededup-live-2.0.0-beta.60.aar
//    https://swiftend-assets-348761024048.s3.eu-west-2.amazonaws.com/sdk/android/facededup-live-2.0.0-beta.60.aar
// 2) app/build.gradle.kts
implementation(files("libs/facededup-live-2.0.0-beta.60.aar"))
implementation(platform("androidx.compose:compose-bom:2024.06.00"))
implementation("androidx.compose.ui:ui"); implementation("androidx.compose.material3:material3")
implementation("androidx.camera:camera-camera2:1.3.4"); implementation("androidx.camera:camera-lifecycle:1.3.4"); implementation("androidx.camera:camera-view:1.3.4")
implementation("com.google.mlkit:face-detection:16.1.7"); implementation("com.google.android.play:integrity:1.4.0")

Then drop in the composable and read the full FacededupResultthe images come back on the result object (selfie + liveness frames), not just the outcome:

import ng.facededup.live.ui.LivenessScreen
import ng.facededup.live.FacededupConfig
import ng.facededup.live.FacededupResult

LivenessScreen(
    FacededupConfig(baseUrl = "https://facededup.ai", subjectId = "user-123"),
    onResult = { r: FacededupResult ->
        when (r.outcome) {
            "live" -> {
                val selfie = r.selfieImageB64          // base64 JPEG (no data: prefix)
                val frames = r.frameImagesB64          // List<String> — liveness frames
                val score  = r.score                   // 0..100
                // upload selfie + frames to your backend, then grant access
            }
            "not_live", "referred" -> { /* reject or send to review — images still on r */ }
            "error" -> { /* r.errorCode: network_error | timeout | camera_unavailable | … */ }
        }
    },
    onCancel = { /* user dismissed */ },
)

FacededupResult fields (package ng.facededup.live):

FieldTypeNotes
outcomeStringlive · not_live · referred · captured (echo) · error
isLive / passedBooleanpassed == (outcome == "live") — gate on this.
scoreInt?0..100 server score; null in echo/error.
selfieImageB64String?Best selfie, raw base64 JPEG.
frameImagesB64List<String>Liveness frames, raw base64 JPEG.
errorCodeString?Null on success. See codes below.
rawJsonString?Full /v1/verify payload — incl. assurance, truedepth, session_signature.
payloadJson use thisString?The canonical envelope — the exact JSON in Sample result below (type, outcome, is_live, score, selfie_image, liveliness_images, device, network, location). Present on every outcome incl. error. Forward this to your backend.
encryptedPayloadString?The result METADATA (user id, request id, verdict, device/network/location) encrypted to your backend key as an opaque token when security.responseEncryption is on — only your private key opens it. The images are NOT inside the token (that would just bloat it) — they ride back in cleartext via selfieImageB64/frameImagesB64 for display. null when encryption is off.
subjectIdString?The user id you passed in, echoed back. Present in images-only mode (the default) to tie images to a user. null when responseEncryption is on — it lives inside the encrypted token instead.
requestIdString?Server request id — correlate the captured images to the server-side decision (dashboard / decision record). null when responseEncryption is on — sealed in the token.

With responseEncryption on, the result contains only the base64 images (selfieImageB64/frameImagesB64, for display), outcome/errorCode (flow control) and encryptedPayload (the token). All other plaintext — user id, request id, verdict, metadata — is dropped and recoverable only by decrypting the token.

🔒 Security you get for free. Every native capture is layered: a server-verified active challenge (your turns/blinks are re-checked on the actual frames), active-illumination reflection (a screen can't bounce back the random light a real face does), continuous passive PAD (screen/replay detection) and continuity tracking (anti swipe/collage). The verify response carries an assurance level — high on iOS Face-ID devices (ARKit TrueDepth 3D depth + ES256 anti-replay session signing), standard elsewhere — so you can gate sensitive actions on assurance == "high". Genuine users are never hard-failed by a flaky secondary check when these primary proofs all hold.
🛡️ Device & anti-fraud signals — collected automatically, no setup. Every native capture also attaches a layer of on-device defenses the server risk-scores. You don't call anything; they ride in the result's device block + the verify payload:
SignalCatches
RASPis_rooted_jailbroken · is_debugger_attached · is_emulator · hook/Frida detectionTampered devices, instrumentation, emulators
Camera-sourcenum_cameras + camera_source (physical front vs external/continuity/virtual)Virtual-camera / injected video feeds
Frame-freshnessframe_integrity (per-frame dHash duplicate/loop detection)Replayed or looped frame sets
Secure capture — Android FLAG_SECURE (blocks screenshot/record/cast) · iOS screen_captured reportRecording the flow for later replay
Attestation + app integrity — Play Integrity / App Attest token · signing-cert hashRepackaged SDK, non-genuine app
Device-motion guard beta.35 — CoreMotion / SensorManager: gyro rate + linear acceleration + attitude tilt vs an action baselineTilting / shaking / moving the phone to fake a head-movement challenge
📲 Device-motion guard (beta.35). Active head-movement challenges (turn / look up & down) must be satisfied by genuine head movement — not by moving the phone (the face pose is camera-relative, so a phone tilt looks like a head turn). During a challenge the device must stay reasonably stable; otherwise:
  • Minor movement → progress pauses, the arc decays, and the user sees "Keep your phone steady" / "Move your head, not the phone". The arc can't be used as a feedback tool.
  • Hold Still requires a stable phone before the challenge starts (and after a security restart), so each action begins from a clean baseline.
  • Escalation per session: 1st violation = warning + retry · 2nd = restart to Hold Still · 3rd = session fails with error code device_motion.
Genuine users only ever see a soft warning; deliberate phone movement restarts, then fails.

Error codes (FacededupError): network_error · server_error · camera_unavailable · timeout · cancelled · unknown_error · device_motion. Branch on these, never on message text.

Everything is configurable via FacededupConfig — branding (colours), text sizes, capture (frame size/quality, count), activeIllumination, submitToServer (server verdict vs. capture-only echo). The WebView SDK below remains the stable default; the native SDK is alpha.

Hosted publicly — no GitHub access or token needed. If you got "Unresolved reference: FacededupConfig", it means the dependency isn't resolving yet — do Step 1, then Sync Project with Gradle Files. See Troubleshooting.

Step 1 — add the repository + dependency

In modern Android projects (AGP 7+) repositories live in settings.gradle.kts. Adding the maven{} block to the module's build.gradle.kts is silently ignored when FAIL_ON_PROJECT_REPOS is set — that's the usual reason it "won't resolve".

// settings.gradle.kts  ← repositories go HERE in modern Android projects
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://swiftend-assets-348761024048.s3.eu-west-2.amazonaws.com/m2") }
    }
}

// app/build.gradle.kts
dependencies {
    implementation("ng.facededup:facededup:1.3.13")
}
// then: Android Studio → File → Sync Project with Gradle Files
// 1) Download the AAR (no auth):
//    https://swiftend-assets-348761024048.s3.eu-west-2.amazonaws.com/sdk/facededup-1.3.13.aar
// 2) Drop it in your app module's libs/ folder, then:
dependencies {
    implementation(files("libs/facededup-1.3.13.aar"))
    // AAR via files() carries no transitive deps — add them explicitly:
    implementation("androidx.core:core-ktx:1.13.1")
    implementation("androidx.appcompat:appcompat:1.7.0")
}
// If you have the repo checked out as part of your Gradle build:
// settings.gradle.kts
include(":facededup")
project(":facededup").projectDir = file("path/to/facededup-liveliness/sdk/android/facededup")

// app/build.gradle.kts
dependencies { implementation(project(":facededup")) }

Step 2 — app setup

minSdk 21. The SDK already declares the CAMERA permission and its WebView activity, so there's nothing to add to your manifest. You don't need to request the camera permission yourself either — the SDK handles the runtime prompt, the WebView, HTTP-auth and the result bridge.

Step 3 — launch the flow

import ng.facededup.sdk.FacededupConfig
import ng.facededup.sdk.FacededupContract

// register in onCreate / as a class field
private val verify = registerForActivityResult(FacededupContract()) { r ->
    when {
        r == null   -> { /* user cancelled */ }
        r.passed   -> { /* verified: r.outcome / r.score / r.enrollmentId */ }
        else       -> { /* not verified: show r.outcome */ }
    }
}

// launch it — the SDK goes STRAIGHT to face capture by default (no menu)
verify.launch(FacededupConfig(
    baseUrl   = "https://facededup.ai",
    licenseKey = "fdk_…",       // your per-tenant key (production auth); or password = "…" for the demo gate
    subjectId = "user-123",
    // flow defaults to "liveness" (direct face capture) — set "select" for the
    // menu, or "enroll"/"authenticate"/"address". method/strictness via config.
))

// read the captured images off the result (for your own compare/storage)
val selfie = r.selfieImage?.image          // base64 JPEG, or null
val frames = r.livelinessImages            // List<FacededupImage> (4–8)
Trust the result server-side — not the client. The SDK result is convenient for UX, but a client result can be forged. Your backend must redeem the signed token before granting access: POST /v1/result/redeem with result_token (single-use, 120s TTL, Ed25519-signed; verify against GET /v1/result/public-key). Treat that server verdict as authoritative; never gate solely on the client passed/outcome. (VAPT F2.)
TypeFieldNotes
FacededupConfigbaseUrlYour Facededup host.
licenseKeyPer-tenant license key (fdk_…) — the production auth. Sent as X-License-Key.
passwordDemo gate password (omit once you use a license key).
subjectIdYour user id, echoed back in the result.
flowDefault "liveness" = camera-only, no menu · "enroll" · "authenticate" · "address" · "select" = show menu.
method"face_liveness" (motion) · "face_number" (read digits aloud).
strictness"lenient" · "standard" · "strict".
agentModetrue = rear camera (agent points it at the customer).
showSettingsShow the in-flow tester panel (default off in prod).
showBackShow the in-flow back button — default off (SDK has no menu to return to).
productName / primaryColorBranding — title + brand colour (hex).
FacededupResultpassedtrue for a clear pass — check this.
outcome / score / decisionlive · referred · not_live, 0–1 score.
enrollmentId / rawEnrollment id (if any) + full JSON payload.
selfieImageFacededupImage? — best selfie (imageType + base64 image).
livelinessImagesList<FacededupImage> — 4–8 liveness frames.

Sample result — the payloadJson envelope

This exact JSON is what result.payloadJson contains (Android & iOS) — one stable shape returned on every outcome, including error. Parse it once, forward it to your backend. It carries the captured frames — one selfie_image plus the liveliness_images — shaped for a downstream face-compare engine, plus the consent-gated device / network / location signals collected at capture:

{
  "type": "liveness",
  "outcome": "live",
  "is_live": true,
  "score": 92,
  "selfie_image": { "image_type": "image_type_2", "image": "<BASE64_SELFIE>" },
  "liveliness_images": [
    { "image_type": "image_type_2", "image": "<BASE64_FRAME_1>" },
    { "image_type": "image_type_2", "image": "<BASE64_FRAME_2>" }
  ],
  "device":   { "os": "ios", "model": "iPhone15,2", "is_emulator": false, /* … */ },
  "network":  { "vpn_suspected": false, /* … */ },
  "location": { "lat": 6.5244, "lng": 3.3792 }
}

An enroll result is the same shape with "type":"enroll" and an "enrollment_id" (e.g. FE-3A9B1B2260) instead of a score.

Always branch on outcome. The SDK delivers exactly one terminal result and the host screen closes on it. Possible values:
outcomeMeaning · what to do
liveLiveness passed (is_live: true). Proceed.
not_liveFailed liveness / spoof suspected. Reject or re-prompt.
referredSent for manual review. Show "under review".
errorVerify could not complete (server/timeout/offline/low-quality). Carries error + error_code ("verify_failed" | "offline"). Re-launch the SDK to retry.
queuedOffline capture stored on-device; verdict arrives later by webhook (Offline mode).
Since 1.3.10 the SDK always returns a result — a verify failure delivers outcome:"error" instead of leaving your callback hanging.

📸 Images-only mode — the DEFAULT (decision on your dashboard)

This is how the native SDK behaves out of the box (returnImagesOnly defaults to true). The app gets back only the captured images — the selfie (hold-still) + liveliness frames — plus the user id and a request id, with outcome:"captured". No verdict, no metadata, no encryption reach the app, and the SDK never blocks the user. The full verdict + all signals are computed and kept server-side for your dashboard / decision. Your back office (not the phone) makes the accept/reject call — correlate via requestId.
The app receives (onResult)Your dashboard / DB receives
Images-only
(default)
selfieImageB64 · frameImagesB64 · subjectId · requestId · outcome:"captured"verdict, score, checks, device/network/location, attack indicators — everything
The SDK sends capture_mode, so the server scores at the lenient baseline (no stepup) — fewer false not_live on borderline devices, since the app isn't gating on it.
FacededupConfig(baseUrl = BASE, subjectId = uid)   // images-only by default
// onResult -> selfieImageB64, frameImagesB64, subjectId, requestId  (no verdict)
// Want the verdict in the app instead? add returnImagesOnly = false
FacededupConfig(baseURL: BASE, subjectId: uid)   // images-only by default
// onResult -> selfieImageB64, frameImagesB64, subjectId, requestId  (no verdict)
// Want the verdict in the app instead? add returnImagesOnly: false

result.payloadJson is a ready-to-forward envelope — one selfie + one in-motion liveliness frame, each {image_type, image}:

{
  "type": "liveness",
  "user_id": "<your subjectId>",
  "request_id": "<correlate to the dashboard decision>",
  "selfie_image": { "image_type": "image_type_2", "image": "<BASE64_SELFIE>" },
  "liveliness_images": [
    { "image_type": "image_type_2", "image": "<BASE64_LIVELINESS>" }
  ]
}

✅ Verify the smart way — 3 rules

The SDK already did the hard part. Liveness, PAD, anti-replay and risk scoring run server-side and on-device. Your job is just to trust the verdict and store the proof.

Applies when you opt into the verdict in the app (returnImagesOnly = false). By default the verdict lives on your dashboard, not the app.

  1. Gate on the verdict, not the score. Use result.passed (≡ outcome == "live"). Don't invent your own score threshold — the server already applied the tenant-tuned one.
  2. Forward payloadJson verbatim to your backend. It's the one stable envelope (same on every outcome). Persist it; never trust a verdict computed on the client.
  3. Handle all five outcomeslive · not_live · referred · error · queued. Re-launch the SDK on error; show "under review" on referred.
onResult = { r: FacededupResult ->
    when (r.outcome) {
        "live"      -> { sendToBackend(r.payloadJson!!); proceed() }   // verified human
        "not_live"  -> rejectOrRetry()                              // spoof suspected
        "referred"  -> showUnderReview()                            // manual review
        "error"     -> relaunch(r.errorCode)                         // network/timeout/quality
        else        -> showUnderReview()                            // queued (offline)
    }
}
onResult: { r in
    switch r.outcome {
    case "live":     sendToBackend(r.payloadJson!); proceed()   // verified human
    case "not_live": rejectOrRetry()                          // spoof suspected
    case "referred": showUnderReview()                        // manual review
    case "error":    relaunch(r.errorCode)                     // network/timeout/quality
    default:        showUnderReview()                        // queued (offline)
    }
}
# Your server — never trust a client-computed verdict.
# The result carries a signed_result token (in rawJson); verify it against
# GET /v1/result/public-key (Ed25519) before honouring "live".
payload = json.loads(body["payloadJson"])
if payload["outcome"] == "live" and verify_signed_result(body):
    store(payload["selfie_image"], payload["liveliness_images"])  # for 1:1 face match
    approve(user)

Troubleshooting

SymptomFix
Unresolved reference: FacededupConfig The dependency isn't on the classpath. Confirm the maven{} repo is in settings.gradle.kts (not the module), the implementation("ng.facededup:facededup:1.3.13") line is present, then Sync Project with Gradle Files and rebuild.
Repo block "ignored" / Build was configured to prefer settings repositories You added maven{} to the module's build.gradle.kts but dependencyResolutionManagement uses FAIL_ON_PROJECT_REPOS. Move the repo to settings.gradle.kts (Step 1).
Could not resolve ng.facededup:facededup:1.3.13 Keep google() + mavenCentral() alongside the Facededup maven repo (the SDK's transitive deps resolve from them). Check the device/CI has internet for the first resolve.
GitHub repo / GitHub Packages "not available" You don't need GitHub at all — use the public maven repo in Step 1. The source repo is private.
Camera doesn't open Test on a real device (emulators often lack a camera). The SDK requests the runtime permission itself.
Direct artifact links (no auth): facededup-1.3.13.aar · POM

iOS SDK (xcframework)

A drop-in binary — FacededupLiveness.xcframework — that runs the full hosted flow in a managed WKWebView and hands you a typed FacededupResult. iOS 14.3+. No WebView, permission, auth, or bridge plumbing.

Native SDK (beta) — FacededupLive 2.0.0-beta.60. A SwiftUI-native alternative (AVFoundation + Vision, no WebView) with the full layered anti-replay stack (active illumination, passive PAD, continuity tracking). iOS 15+. Each XCFramework slice carries a valid Info.plist, so Embed & Sign validates cleanly.

Step 1 — drag-in the XCFramework (no GitHub needed): download & unzip, drag FacededupLive.xcframework into your target, then in Target ▸ General ▸ Frameworks, Libraries & Embedded Content set it to Embed & Sign:

// https://swiftend-assets-348761024048.s3.eu-west-2.amazonaws.com/sdk/ios/FacededupLive-2.0.0-beta.60.xcframework.zip

Step 2 — add NSCameraUsageDescription to your app's Info.plist (required, or the camera silently fails).

Step 3 — present it. onResult hands back the full FacededupResultthe captured images are on the result (selfie + frames):

import FacededupLive

FacededupLiveness(config: FacededupConfig(baseURL: "https://facededup.ai", subjectId: "user-123")) { result in
    if result.passed {
        let selfie = result.selfieImageB64       // base64 JPEG (no data: prefix)
        let frames = result.frameImagesB64       // [String] liveness frames
        let score  = result.score                // 0..100
        // upload selfie + frames to your backend, then grant access
    } else if result.outcome == "error" {
        // result.errorCode: network_error | timeout | camera_unavailable | …
    } else {
        // not_live / referred — images still on result
    }
} onCancel: { dismiss() }

FacededupResult members (module FacededupLive): outcome · isLive/passed · score: Int? · selfieImageB64: String? · frameImagesB64: [String] · errorCode: String? · rawJson: String?. Error codes (FacededupError): network_error · server_error · camera_unavailable · timeout · cancelled · unknown_error · device_motion.

SwiftPM is also available from the (private) SDK repo — ask for read access if you prefer a package dependency.

Configurable via FacededupConfig (branding, text, capture, submitToServer). The xcframework below remains the stable default; the native SDK is alpha.
Recommended: the prebuilt binary (1.3.11). The published .xcframework ships a proper Swift module in every slice (device + simulator), so import FacededupLiveness works with no source build and no GitHub token — see Step 1 (recommended) below. Prefer building from source instead? That path also works and is shown as the alternative.

Step 1 (recommended) — binary via Swift Package / S3

SPM downloads the zip and verifies the checksum — no repo access needed. Jump to the binary snippet, or use the git package with from: "1.3.11".

Step 1 (alt) — Swift Package from source

// Xcode ▸ File ▸ Add Package Dependencies… ▸ paste the repo URL (needs read access):
//   https://github.com/facededup/ios-sdk.git   → add product "FacededupLiveness"
// or in your app's Package.swift (use the 1.3.x STABLE line, NOT 2.0.0 alphas):
dependencies: [
    .package(url: "https://github.com/facededup/ios-sdk.git", from: "1.3.11"),
],
targets: [
    .target(name: "YourApp", dependencies: [
        .product(name: "FacededupLiveness", package: "ios-sdk")]),
]

No repo access? Clone it and use a local package: .package(path: "../ios-sdk"). This compiles the SDK from sdk/ios/Sources and exposes import FacededupLiveness with a real .swiftmodule.

Binary snippet — point a binaryTarget at S3

The fastest path: no repo access, no source build. The root Package.swift already wires this binaryTarget (url + checksum managed by scripts/build-ios-xcframework.sh); resolve it with FACEDEDUP_USE_BINARY=1, or paste the binaryTarget straight into your own app's Package.swift:

// In your app's Package.swift — point a binaryTarget straight at S3.
// No git URL, no token: SPM downloads the zip and verifies the checksum.
targets: [
    .binaryTarget(
        name: "FacededupLiveness",
        url: "https://swiftend-assets-348761024048.s3.eu-west-2.amazonaws.com/sdk/ios/FacededupLiveness-1.3.11.xcframework.zip",
        checksum: "c53b3094a2d340b72374f5901a5d21b3778586580d37e1623aac64ee20333f79"
    ),
    .target(name: "YourApp", dependencies: ["FacededupLiveness"]),
]
// 1) Download + unzip (no auth):
//    https://swiftend-assets-348761024048.s3.eu-west-2.amazonaws.com/sdk/ios/FacededupLiveness-1.3.11.xcframework.zip
// 2) Drag FacededupLiveness.xcframework into your Xcode project.
// 3) Target ▸ General ▸ Frameworks, Libraries & Embedded Content →
//    set FacededupLiveness.xcframework to "Embed & Sign".
// That's it — no SPM, no CocoaPods, no GitHub.
Add NSCameraUsageDescription (and NSMicrophoneUsageDescription for voice methods) to your app's Info.plist.

Step 2 — present the flow

import FacededupLiveness

let vc = FacededupVerificationController(config: .init(
    baseURL: URL(string: "https://facededup.ai")!,
    licenseKey: "fdk_…",       // per-tenant key (production auth); or password: "…" for the demo gate
    subjectId: "user-123"
)) { result in
    if result.passed {
        // verified — result.outcome / result.score / result.enrollmentId
    } else {
        // not verified — show result.outcome
    }
}
present(vc, animated: true)
TypeMemberNotes
FacededupConfigbaseURLYour Facededup host (a URL).
licenseKeyPer-tenant license key (fdk_…) — the production auth. Sent as X-License-Key.
passwordDemo gate password (nil once you use a license key).
subjectIdYour user id, echoed back in the result.
FacededupResultpassedtrue for a clear pass — check this.
outcome / score / decision / enrollmentId / rawlive · referred · not_live, 0–1 score, full payload.

Prefer a delegate? Conform to FacededupDelegate (facededup(_:didFinish:) / facededupDidCancel(_:)) and set vc.delegate instead of the onFinish closure. The controller grants the camera, supplies the demo password (HTTP Basic), shows an offline/retry state, and reports the result via the JS bridge.

Web embed (hosted flow)

Open https://facededup.ai/demo/ (iframe, popup, or redirect). When the flow finishes it calls a host bridge — window.webkit.messageHandlers.facededup (iOS) and window.FacededupNative.onResult(json) (Android). For plain web, listen for the same payload via your wrapper or a redirect with the signed result_token. No build — the hosted flow ships its own UI + anti-spoof stack (active illumination, presence lock, 720p).

Entry points

Two ready-made URLs, plus an opt-in chooser. All configurable with the query options below.

URLOpens onUse for
https://facededup.ai/demo/Straight to the camera (liveness capture)Face-only liveness — the SDK default.
https://facededup.ai/demo2/Government-ID + Face journey — country → ID type → number entry / live document scan → face capture & matchKYC / onboarding that must bind a face to a government ID (NIN, BVN, National ID, Passport, Driver's License, Voter's card; 54 African countries).
https://facededup.ai/demo/?flow=selectThe full chooser menu (Enrol Face · Validate Government ID · Authenticate · Address)Previewing every journey in one place.

URL options

Append as query parameters, e.g. https://facededup.ai/demo/?flow=liveness&license=fdk_…&subject=user-123&method=face_number&strictness=strict. Precedence (later wins): built-in defaults → config.json → host-injected window.FACEDEDUP_CONFIG → URL parameters.

OptionValuesWhat it does
flowliveness (default) · validate · enroll · authenticate · address · selectWhich journey to run. liveness = straight to camera; validate = Government-ID + Face (the /demo2 default); select = show the chooser menu.
methodface_liveness (default) · face_numberLiveness challenge type: motion (turn head to prompts) or read-numbers-aloud (server issues digits, mic STT + audio anti-spoof while the face is captured).
strictnesslenient (default) · standard · strictHow hard the check is to pass. strict requires a reset to centre between actions; hardest to game.
licensefdk_…Per-tenant license key (production auth). Also marks the launch as a real tenant (hides the tester Settings panel). See Authentication.
subjectstringYour user identifier, echoed back on the result so you can tie the captured images to a user.
agent1 / 0Agent mode — uses the rear camera (an operator points the phone at the customer).
colorhex, URL-encoded (%230a3d62)Primary brand colour (drives the accent, ring, buttons).
productstringProduct name shown in the header.
settings1 / 0Show/hide the tester Settings panel (auto-hidden when a license is set).
back1 / 0Show/hide the in-flow back button (SDK launches set 0).
apiBaseURLOverride the API origin (defaults to the page's own origin). Rarely needed.
Same-origin recommended. The flow POSTs biometric frames to apiBase (default: the page origin) — keep it same-origin so the server reads the real client IP for the risk engine and no cross-origin biometric transfer occurs. For non-URL configuration, serve a config.json next to the page or inject window.FACEDEDUP_CONFIG before load (same keys, grouped under behavior/ui/branding/api).

Web SDK (npm) — @facededup/liveness-web

A framework-agnostic TypeScript client for teams that want to drive the protocol and own their own capture UI (React/Vue/Svelte/vanilla). It handles consent → request → challenge → verify, device/network/location signals, and the result contract; you render the camera UI. (For a turnkey UI instead, use the hosted flow above.)

npmnpm i @facededup/liveness-web · or from the S3 registry — current 0.1.1
app.ts — one-call liveness
import { LivenessClient, startCamera, grabFrame } from "@facededup/liveness-web";

const client = new LivenessClient({ baseUrl: "https://facededup.ai", licenseKey: "fdk_live_…" });
const video = document.querySelector("video")!;
const stop = await startCamera(video);                 // getUserMedia → <video>

const result = await client.run({
  subjectId: "user-123",
  signalScopes: ["device_signals", "precise_location"],  // user-consented scopes
  collect: { collectPreciseLocation: true, appVersion: "1.0.0" },
  capture: async (actions) => {
    // Drive YOUR UI through `actions` (e.g. ["blink","turn_left"]) and grab a
    // frame proving each. The server verifies the motion server-side.
    return actions.map((a) => grabFrame(video, a));
  },
});
stop();
console.log(result.outcome, result.is_live, result.score, result.signed_result?.token);

Result (VerifyResult) carries outcome (live·not_live·referred), is_live, score, the captured selfie_image + liveliness_images, the echoed device/network/location, a signed result_token, and (when the tenant enables it) the encrypted encryptedPayload envelope — see Result encryption.

ExportUse
LivenessClientnew LivenessClient({baseUrl, licenseKey}).run() (one call) or the granular grantConsent / openRequest / getChallenge / verify.
startCamera(video)getUserMedia → bind to your <video>; returns a stop().
grabFrame(video, action?)One base64 JPEG frame (optionally tagged with the action it proves).
collectDeviceContext(opts)Device/network/location signals for the risk engine (consent-scoped).
Headless ≠ no anti-spoof. The active-illumination reflection check is part of the hosted flow's UI; with the npm SDK you own the UI, so the server-side passive PAD, multi-frame motion gate, and per-tenant policy are what enforce liveness. Want the active-illumination widget as a drop-in for the npm SDK? It's on the roadmap.

Flutter · React Native · React

React Native and Flutter drive the NATIVE SDK — not a WebView. Install the bridge package and one call presents the full on-device native flow (CameraX + ML Kit on Android, AVFoundation/ARKit + Vision on iOS) and returns the typed result. Same engine and anti-spoof stack as the Android / iOS SDKs above.

PlatformPackageCall
React Native@facededup/liveness-react-nativeawait startLiveness({ baseUrl, subjectId, licenseCert })
Flutterfacededup_livenessawait FacededupLiveness.start(LivenessConfig(baseUrl:…, subjectId:…))
// npm i @facededup/liveness-react-native ; cd ios && pod install
import { startLiveness } from "@facededup/liveness-react-native";

const r = await startLiveness({
  baseUrl: "https://facededup.ai", subjectId: "user-123",
  licenseCert: "…",             // production auth
});
if (r.outcome === "captured" || r.is_live) {
  // upload r.selfie_image / r.liveliness_images / r.encrypted_payload
}

Both packages depend on the published native SDK (Android AAR from the S3 maven; iOS xcframework fetched at pod install). Add the camera usage strings as for the native SDKs (NSCameraUsageDescription / <uses-permission android:name="android.permission.CAMERA"/>).

For web React (and any pure-web framework), use the hosted flow in an iframe/popup, or the npm Web SDK — the browser has no native camera SDK.

WebView fallback (still supported): if you can't add a native build step, any framework can still embed the hosted flow in a WebView/iframe pointed at https://facededup.ai/demo/?flow=liveness&license=fdk_… and read the result from the message channel. The native bridge above is preferred — same on-device engine as the platform SDKs, no WebView.

Offline mode

Facededup is built to work where the network isn't. The face engine ships inside the SDK — the MediaPipe FaceLandmarker model and WASM runtime are bundled into the app and served to the WebView locally — so the camera, the active-liveness challenge, face detection and quality checks all run fully on-device, in airplane mode. No CDN, no first-run download, no round-trip per frame.

What needs the network, and what doesn't. Anything that captures or analyses the face locally works offline. Anything that cross-references an external authority (the NIN/BVN registry, a geocoder) needs connectivity — there's no on-device copy of the national database.
Product / stepCapture & on-device checkFinal scoring / lookup
Face LivenessOffline ✓ challenge, landmarks, qualityOnline server liveness score
Document VerificationOffline ✓ MRZ scan, selfie captureOnline MRZ check digits + face match
Face EnrollmentOffline ✓ selfie capture + qualityOnline gallery enroll
Biometric KYC (NIN/BVN)Offline ✓ liveness captureOnline required registry 1:1
Address VerificationOffline ✓ GPS captureOnline required geocode + match

Two ways to run offline

1 · On-device capture, server scoring (default). Liveness is gated on the server so a client can't claim a pass it didn't earn. The engine still runs locally for the live UX (challenge, framing, quality), and only the captured frames are submitted when a connection is available. The submit call retries with backoff; the demo APK shows an offline/retry state until it succeeds.

2 · Capture now, submit later (stored offline jobs). For field agents with no signal, capture the session and persist it, then submit when the device reconnects. The result is identical to an online capture — verification always happens server-side, so a stored job carries the same assurance.

// Capture the session on-device and keep the frames — no submit yet.
// Equivalent to "skip API submission": returns the captured payload.
verify.launch(FacededupConfig(
    baseUrl   = "https://facededup.ai",
    subjectId = "user-123",
    offlineMode = true,        // capture & store, don't submit
))
// result.passed is null while offline; result.raw holds the stored job id.
// Later, when the device is back online — submit the stored frames to the
// same /v1/verify endpoint. Server scores it; you get the signed result.
curl -u :PASSWORD -X POST https://facededup.ai/v1/verify   -H "Content-Type: application/json"   -d @stored-job.json
// → { "outcome": "live", "score": 0.95, "result_token": "…" }
Why scoring stays server-side. An attacker controls the client, so a client-rendered "live" verdict is worthless. Facededup always makes the live/not-live decision on the server — offline mode defers that decision until the frames arrive, it never fakes it on-device.

Customization

Tune the flow without forking the SDK — pass options on FacededupConfig (mobile) or as query params on the /demo/ embed (web).

OptionEffect
methodface_liveness · face_voice · face_number · assisted — which capture flow to run.
risk_levelbaseline or stepup — step-up adds extra challenge actions.
subjectIdYour user id, echoed back in the result and shown in the console.
tenant_idRoutes decisions to the right tenant queue in the console.
offlineModeCapture & store without submitting (see Offline mode).
Theme / brandAccent colour + logo follow your tenant config; the flow inherits it automatically.
Locale: the flow ships English copy; challenge prompts are icon-led so they read across languages. Ask us to enable additional language packs for your tenant.
POST/v1/consent

Record the subject's explicit consent before any capture. Refusals are recorded too.

{
  "subject_id": "user-123",
  "purpose": "nin_token_issuance_liveness",
  "lawful_basis": "consent",
  "channel": "web",
  "accepted": true
}
{ "consent_id": "cn_a1b2c3", "accepted": true, "recorded_at": "2026-06-13T12:00:00Z" }

Data we collect

Every field below is consent-gated: the device/network metadata is sent only when the host app obtained the device_signals scope, and GPS only with precise_location. The client IP is never trusted from the device — the server derives the true source IP and enriches it (ASN / geo / VPN / Tor / hosting). Data is minimised per the configured posture, retention-bound, and auditable (NDPA / DPIA).

WhereFields
POST /v1/consentsubject_id, purpose, signal scopes (device_signals, precise_location)
POST /v1/request
(identity)
subject_id, consent_id, method, reason, tenant_id, operator_id, registration_centre, device_context
device_context.deviceplatform, os, os_version, model, manufacturer, app_version, sdk_version, sdk_launch_count, screen + width/height_px, screen_refresh_rate, system_architecture, locale, languages, timezone, timezone_offset_minutes, device/app/total/free memory, build_brand/device/hardware/product/fingerprint, package_name, host_application, device_app_hash, num_cameras, battery_level/charging, supports_hardware_attestation, device_motion_rms, device_id, is_emulator, is_rooted_jailbroken, is_debugger_attached
device_context.networkconnection_type, downlink_mbps, rtt_ms, save_data, online, carrier, vpn_suspected, proxy_suspected, client_reported_ip (hint only)
device_context.location
(precise_location only)
lat, lng, accuracy_m, source, captured_at
device_context.timing / attestationclient_timestamp, capture_duration_ms, challenge_latency_ms, attestation_token (Play Integrity / App Attest)
POST /v1/verify
(capture)
request_id, session_id, nonce, attestation_token, client_actions, frames (1–30 × {image_b64, proves_action} — selfie + light proving frames), transcript, audio_present, audio_b64, video_b64, gps_lat/lon/accuracy_m, face_attributes (head_pose, eyes_open, mouth_closed, inter_pupil_px), pad_strict

The device + network metadata feeds the transparent, rule-based risk engine (emulator / root / debugger / custom-ROM, virtual-camera, photo-on-stand via device-motion, app repackaging, VPN/proxy, device-farm velocity & identity-mill) — every adverse decision carries a human-readable reason (NDPA s.37). Server stores metadata in the decision record; frames are encrypted at rest in S3.

Request

POST/v1/request

Open a verification request bound to a consent. methodface_liveness · face_voice · face_number · assisted; risk_levelbaseline · stepup.

{
  "subject_id": "user-123",
  "consent_id": "cn_a1b2c3",
  "method": "face_liveness",
  "risk_level": "baseline",
  "tenant_id": "harrys-living"
}
{ "request_id": "rq_77f0", "method": "face_liveness", "risk_level": "baseline" }

Challenge

POST/v1/challenge

Returns a randomized, time-boxed active-liveness challenge (3 actions, 20s TTL, nonce-protected). Action pool: turn_left · turn_right · look_up · look_down · blink · smile. Each sequence is guaranteed ≥2 head-motion actions (the server liveness is a motion check), with blink/smile added as expression variety.

{ "request_id": "rq_77f0" }
{
  "session_id": "se_4d2",
  "nonce": "Yk3…q9",
  "actions": ["turn_left", "look_up", "turn_right"],
  "method": "face_liveness",
  "expires_at": "2026-06-13T12:00:20Z"
}

Verify

POST/v1/verify

Submit 1–30 captured frames. Each action frame is tagged with the action it proves; portrait frames use null. Echo the session_id + nonce from the challenge. Returns a signed, single-use result.

{
  "request_id": "rq_77f0",
  "session_id": "se_4d2",
  "nonce": "Yk3…q9",
  "frames": [
    { "image_b64": "<jpeg>", "proves_action": "turn_left" },
    { "image_b64": "<jpeg>", "proves_action": null }
  ],
  "audio_present": false,
  "attestation_token": "<Play Integrity / App Attest>",  // device attestation, verified server-side
  "illumination": { "responded": true, "delta": 34.0 },     // active-illumination reflection evidence
  "pad": { "screen_score": 0.04, "verdict": "genuine" }    // on-device screen/replay PAD
}
{
  "decision_id": "dc_9a1",
  "outcome": "live",            // live · referred · not_live
  "score": 0.95,
  "threshold": 0.6,
  "attestation_verified": true,
  "face_genuineness": "GENUINE",   // GENUINE · REPLAY_ATTACK · PRINT_ATTACK · SYNTHETIC_FACE · …
  "pad_gate": { "triggered": false, "reasons": [] },
  "result_token": "<ed25519-signed, 120s TTL>",
  "checks": [ { "name": "active_challenge", "passed": true } ]
}
Decision band: score ≥ 0.6 → live; within 0.07 below → referred; lower → not_live. Carry the result_token into the identity step to bind the match to a proven-live face.
Layered anti-replay (fail-closed). Challenge-response alone is not enough — a recording satisfies every action. The native SDK adds active illumination (the screen emits random light; the face must reflect it — a replayed video can't), continuous passive PAD (moiré / reflection / texture / flicker → pad.screen_score), and continuity tracking (one persistent tracked face from challenge start — a swipe/collage of posed photos breaks it). The server takes the more restrictive of its own verdict and these signals: illumination.responded == false or a strong padnot_live (pad_gate.triggered), recorded as face_genuineness: REPLAY_ATTACK. Client signals can only add restriction. Set ATTESTATION_PROVIDER (+ Play Integrity / App Attest credentials) so attestation_verified is enforced rather than advisory.

Liveness policy — strictness

How strict a liveness check is, is set per tenant in the console (Settings → Liveness policy) and applied server-side on every verification — independent of anything the client requests. No SDK or code change is needed to re-tune it.

LevelPass cutRefer bandDeepfake + anti-spoofPassive-PAD floorActive challenge
Lenient≥ 50%±5%offoffrequired
Standard (default)≥ 60%±7%offonrequired
Strict≥ 70%±10%ononrequired
Offoffoffnot required
Off = demo only. In off mode any valid capture with a face passes instantly — no challenge, score, engine or PAD gate. Zero anti-spoof; never use for real onboarding. The applied level is recorded on each decision (strictness) and shown on the case's Metadata tab.
GET/v1/console/liveness-config
PUT/v1/console/liveness-config · { "strictness": "lenient|standard|strict|off" } (super-admins target a tenant with ?tenant=)

Identity verify — Face + NIN/BVN (1:1)

POST/v1/identity/verify

After liveness passes, 1:1-match the live selfie against the authority photo held for a NIN/BVN, and return the profile. NCC §4 band: ≥85 match · 70–84 refer · <70 no_match.

{
  "id_type": "nin",             // "nin" | "bvn"
  "id_number": "12345678901",
  "selfie_b64": "<jpeg>",
  "result_token": "<from /v1/verify>"
}
{
  "found": true, "decision": "match", "match_score": 92.4,
  "full_name": "Ada N.", "date_of_birth": "1991-04-17",
  "id_number_masked": "*******8901", "source": "NIMC",
  "authority_photo_b64": "<jpeg>", "spoof": false
}
POST/v1/identity/search

Resolve a live selfie to an identity with no number entered, then confirm with a 1:1 compare.

{ "selfie_b64": "<jpeg>", "result_token": "<from /v1/verify>" }

Document — parse MRZ

POST/v1/document/mrz

Parse + validate an ICAO 9303 machine-readable zone (TD1 3×30 / TD2 2×36 / TD3 2×44), including the 7-3-1 check digits.

{ "mrz": "P<NGAADA<<…\nL898902C36NGA9001…" }

Document — verify + face match

POST/v1/document/verify

Validate the MRZ and 1:1-match the live selfie against the document photo. Set chip_verified: true when the NFC chip was read & authenticated (highest assurance). This is the "pass the ID image + selfie" path — no registry lookup needed.

{
  "mrz": "P<NGA…",
  "document_photo_b64": "<id photo jpeg>",
  "selfie_b64": "<live selfie jpeg>",
  "chip_verified": false,
  "result_token": "<from /v1/verify>"
}

Address verify — geocode + GPS match

POST/v1/address/verify

Confirm a postal address by geocoding it and checking the subject's captured device GPS is within range (OkHi-style on-site proof). Decision band by distance: match (≤ match radius) · refer · no_match. Omit lat/lon for geocode/validation only (returns refer — no presence proof).

{
  "address": "12 Marina St, Lagos Island, Lagos",
  "lat": 6.4541, "lon": 3.3947,   // device GPS at the location
  "accuracy_m": 12,
  "subject_id": "user-123"
}
{
  "decision": "match",            // match · refer · no_match · not_found
  "formatted": "12 Marina St, Lagos Island…",
  "distance_m": 38.4,
  "match_radius_m": 150,
  "geocode": { "lat": 6.4538, "lon": 3.3949, "source": "google" },
  "decision_id": "…"
}
Set a geocoder with ADDRESS_GEOCODER_URL (Google / Mapbox / Nominatim shapes auto-detected). Until then a mock provider returns refer — it never fabricates a match.

Face enroll

POST/v1/face/enroll

Enroll a live selfie and receive an enrollment id (FE-…) for later 1:N search. Pass the signed result_token from /v1/verify to bind the enrolment to a proven-live face — only then is the face indexed into the dedup gallery (a spoof can never enter). Tenant-scoped: dedup compares only within the same tenant.

{
  "selfie_b64": "<jpeg>",
  "subject_id": "user-123",
  "tenant": "harrys-living",
  "country": "NG",
  "result_token": "<from /v1/verify — binds to a live face>"
}
{
  "enrollment_id": "FE-3A9B1B2260", "status": "enrolled",
  "live_verified": true,           // indexed into the gallery (live proof present)
  "dedup": { "decision": "no_match", "score": 41.2, "threshold": 62.0 }
}
Without a valid live result_token the call still issues an id but does not index the face (live_verified:false). A not_live/expired/replayed token → 403.

Face dedup — 1:N within tenant

POST/v1/face/dedup

Check whether a live face is already enrolled in the tenant's gallery, without enrolling. Returns a banded decision: duplicate (≥ threshold) · refer (borderline → human review, e.g. twins) · no_match. A non-face image returns 422.

{ "selfie_b64": "<jpeg>", "tenant": "harrys-living" }
{
  "decision": "duplicate",        // duplicate · refer · no_match
  "score": 94.1, "threshold": 62.0,
  "match": { "enrollment_id": "FE-…", "subject_id": "user-007", "similarity": 94.1 },
  "gallery_size": 1283
}
Availability: dedup runs only when enabled for your tenant and a recognition model is configured; thresholds are calibrated per deployment. Ask us to switch it on.

Result signing

Every /v1/verify result is an Ed25519-signed, single-use token (120s TTL) binding the outcome to a request_id. Verify it offline with the public key, then redeem it once.

GET/v1/result/public-key
POST/v1/result/redeem
{ "token": "<result_token>" }   // 200 = valid & first use; 409 = already redeemed

Licensing & encrypted results

The native SDK is gated by a per-tenant license cert and can return the result end-to-end encrypted to a key only you hold. Both work fully offline — the cert is verified on-device (no network) and the result is encrypted on-device.

How it fits together.
  1. We mint you a cert (POST /v1/license/issue) bound to your tenant_id, your app's bundle id / package name, an expiry, and your encryption public key.
  2. You pass it to the SDK as FacededupConfig.licenseCert. The SDK verifies it locally (ES256 signature + expiry + bundle) before any capture — invalid → a license_* error.
  3. The SDK encrypts the result to your key → FacededupResult.encryptedPayload. Only your private key opens it. The server re-validates the cert at /verify (signature + expiry + revocation) — the same moment an offline verdict resolves, so offline isn't defeated.

Step 1 — get your cert (2 minutes)

The cert encrypts results to your key, so you generate the keypair and keep the private key. Send us only the public key + your app's bundle id / package name; we mint the cert.

# ECIES-P256 (recommended) — small/fast. Or RSA for legacy backends.
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out tenant.pem   # KEEP private
openssl pkey -in tenant.pem -pubout -outform DER | base64                        # SEND us this

We return a license_cert bound to your tenant_id, your bundle id(s), your public key, an expiry and a quota. It is NOT a secret (the public key can't decrypt) — but the matching private key is: never ship it in the app.

Step 2 — configure the SDK

Add the cert — that's it (Android & iOS):

FacededupConfig(baseUrl = BASE, subjectId = uid,
    licenseCert = "<your-cert>")   // verified offline + ENCRYPTS by default
FacededupConfig(baseURL: BASE, subjectId: uid,
    licenseCert: "<your-cert>")   // verified offline + ENCRYPTS by default
Encrypted by default. The moment a valid licenseCert is set, the SDK returns results encrypted to your key only — no flag needed. The selfie + liveliness images AND all metadata/verdict live solely inside encryptedPayload; the cleartext payloadJson / selfieImageB64 / frameImagesB64 / rawJson are dropped (only outcome / errorCode stay cleartext for flow control). Decrypt server-side for your dashboard + decision.
ConfigDefaultEffect
returnImagesOnlytrue defaultApp gets ONLY the images + subjectId/requestId (decision on your dashboard); no verdict/metadata/encryption. Set false to receive the full verdict in the app — that re-enables the rows below.
encryptedResultOnlytrueWith a cert → encrypted-only. Set false to ALSO get the cleartext (debugging).
requireLicensefalseSet true to forbid any cleartext — no/invalid cert fails fast (license_missing).
No cert? There's no key to encrypt to, so the result stays cleartext (additive rollout). Use requireLicense = true if a missing cert must hard-fail instead.

Step 3 — decrypt on your backend

Decrypt encryptedPayload with your private key (Python; mirror in Node/Java/Go). It decrypts to the canonical payload. Two schemes, chosen per-tenant by the cert: RSA-OAEP-256 or ECIES-P256 — both AES-256-GCM:

from cryptography.hazmat.primitives.asymmetric import padding, ec
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import json, base64
def d(x): return base64.b64decode(x)

def decrypt(env, priv):                       # env = json.loads(encryptedPayload)
    if env["alg"].startswith("RSA"):
        k = priv.decrypt(d(env["enc_key"]),
            padding.OAEP(padding.MGF1(hashes.SHA256()), hashes.SHA256(), None))
    else:                                     # ECIES-P256
        epk = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), d(env["epk"]))
        k = HKDF(hashes.SHA256(), 32, None, b"facededup-ecies-v1").derive(
                priv.exchange(ec.ECDH(), epk))
    return AESGCM(k).decrypt(d(env["iv"]), d(env["ciphertext"]) + d(env["tag"]), None)

Operator endpoints (Facededup-side, header X-License-Admin-Key) — how certs are minted/rotated/revoked; integrators don't call these:

POST/v1/license/issue — mint a cert for a tenant
POST/v1/license/refresh — re-issue with a fresh expiry
POST/v1/license/revoke — revoke by key_id (takes effect at the next verify)
GET/v1/license/public-key — the master public key the SDKs embed (audit)
{ "tenant_id": "acme", "enc_pubkey": "<your SPKI DER, base64>",
  "enc_alg": "ECIES-P256", "bundle_ids": ["com.acme.kyc"], "ttl_days": 60 }
// 200 -> { "license_cert": "...", "key_id": "...", "tenant_id": "acme" }

During the additive rollout the cert is optional (validated + logged). Enforcement is switched on per-deployment when your tenants have certs.

Enterprise response encryption beta.36

Public-key response encryption so that only your backend can read the SDK's result — even if an attacker fully reverse-engineers the mobile app. The SDK only encrypts: it holds no private key, implements no decryption, and stores no long-term secret. This is independent of, and preferred over, the license-cert encryption above.

Architecture

  • SDK (mobile): collects the final result (+ challenge / backend liveness / dedup results), serializes it, encrypts it to a backend PUBLIC key, returns only the ciphertext envelope.
  • Backend (you): owns the PRIVATE key, decrypts, validates integrity, shows the result in your dashboard. The private key never leaves the backend.
Hybrid encryption. A random AES-256-GCM content key encrypts the JSON; that AES key is wrapped with the backend's RSA-4096 (default) or EC public key. Strong security, large-payload performance, encryption completes in milliseconds and runs off the UI thread.

Public-key sources & rotation

GET/v1/sdk/public-key

Returns the current backend public key the SDK encrypts to — rotation-aware via keyId, cached on device (offline-safe). Sources: REMOTE (this endpoint, default) · EMBEDDED (compiled into the SDK) · INTEGRATOR (you supply it).

// 200
{ "version":"2.0", "keyId":"e48a7ea62ab69d63",
  "publicKey":"<base64 SPKI DER>",
  "algorithm":{ "payload":"AES-256-GCM", "keyExchange":"RSA-4096" },
  "cacheMaxAgeSeconds":1800 }

Rotation: change the backend key any time — the SDK re-fetches after its cache TTL and stamps the new keyId. Keep the OLD private key decryptable for in-flight payloads via env SDK_ENC_PRIVATE_KEY_B64_<oldKid>. No SDK update needed.

Per-session keys (preferred)

POST/v1/sdk/session

With sessionKeys:true, the SDK first mints a per-verification ephemeral keypair and encrypts to THAT — minimising the blast radius of any single key. The private key lives only on the backend (TTL'd) and is used once at decrypt, then expires.

// POST {} -> 200
{ "version":"2.0", "sessionId":"…", "keyId":"…",
  "publicKey":"<base64 SPKI>", "algorithm":{…}, "expiresAt":1750000000 }

Encrypted payload (what the SDK returns)

Metadata only — images are not encrypted. The token seals the result metadata (user id, request id, verdict, device/network/location). The captured images are NOT inside it — they're returned in cleartext (selfieImageB64/frameImagesB64) for in-app display, so the token stays small and fast (no double-encrypting tens of KB of base64).

Default: encryptedPayload is a single opaque token — one base64 string with no field names and no algorithm label, so the result never telegraphs the scheme. The keyId, iv and tag still travel (decryption needs them) but as unlabeled bytes; the backend infers the algorithm from the key. Just paste the whole string into the console Decrypt result tool or POST it to /v1/sdk/decrypt.

"encryptedPayload": "9k3Jd…<one opaque base64 blob>…=="

The decrypt endpoints also accept the readable JSON envelope below (back-compat). The encrypted plaintext wraps integrity metadata (version, sessionId, transactionId, sdkVersion, nonce, timestamp), so a single AES-GCM tag protects both the result and its anti-tamper / anti-replay fields:

{
  "version": "2.0",
  "sessionId": "…",
  "keyId": "…",
  "algorithm": { "payload": "AES-256-GCM", "keyExchange": "RSA-4096" },
  "encryptedKey": "<RSA-OAEP-256 wrapped AES key>",
  "iv": "…", "tag": "…",
  "payload": "<base64 AES-256-GCM ciphertext>"
}

EC keys carry epk (ephemeral public point) instead of encryptedKey.

Backend decryption

POST/v1/sdk/decrypt

Decrypt + integrity-validate an envelope (dashboard / back-office, admin-gated by X-Sdk-Admin-Key). Or decrypt with your own private key — the envelope is standard hybrid RSA-OAEP-SHA256 + AES-256-GCM. The backend rejects modified payloads (bad tag), replays (nonce reuse), expired sessions, clock-skew beyond the window, and unknown keyIds.

# POST the envelope, admin header -> 200
{ "valid": true, "result": { … the decrypted JSON … }, "meta": { "sdkVersion", "nonce", "timestamp", … } }

# Or decrypt yourself (Python):
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import base64, json
b = lambda x: base64.b64decode(env[x])
aes = priv.decrypt(b("encryptedKey"), padding.OAEP(padding.MGF1(hashes.SHA256()), hashes.SHA256(), None))
pt  = AESGCM(aes).decrypt(b("iv"), b("payload") + b("tag"), None)
result = json.loads(pt)["result"]

SDK configuration

On by default (beta.48+). Response encryption is enabled out of the box — even FacededupConfig(baseURL:, subjectId:) with no security block encrypts. To DISABLE it, pass security: nil (Swift) / security = null (Kotlin), or SecurityConfig(responseEncryption: false).
// iOS (Swift)
FacededupConfig(baseURL: "https://facededup.ai", subjectId: "user-123",
  security: SecurityConfig(responseEncryption: true,
                          publicKeySource: .remote,   // .embedded / .integrator
                          cachePublicKey: true, cacheDurationMinutes: 30,
                          sessionKeys: false))

// Android (Kotlin)
FacededupConfig(baseUrl = "https://facededup.ai", subjectId = "user-123",
  security = SecurityConfig(responseEncryption = true,
                            publicKeySource = SecurityConfig.KeySource.REMOTE,
                            cachePublicKey = true, cacheDurationMinutes = 30,
                            sessionKeys = false))

When on, the SDK returns ONLY encryptedPayload (the v2 envelope) — payloadJson, images and metadata are nil/empty; outcome/errorCode stay cleartext for host flow. If encryption can't complete (and no cached/embedded key), the result fails closed with errorCode = device… encryption_failed — never cleartext.

Backend setup

Provision the master private key (you set it; it is never committed): ./scripts/setup-sdk-encryption-key.sh generates an RSA-4096 keypair. Because an RSA-4096 key is too large for a Lambda env var, store it in SSM Parameter Store (SecureString) and point the backend at it with SDK_ENC_PRIVATE_KEY_SSM (the Lambda role needs ssm:GetParameter + kms:Decrypt). Smaller EC keys may instead use the SDK_ENC_PRIVATE_KEY_B64 env var directly. For per-session keys across Lambda containers, create a DynamoDB table (PK session_id, TTL ttl) and set SDK_SESSION_KEY_TABLE.

Security switchboard

GET/v1/security
PUT/v1/security

Inspect or toggle the enforcement gates. enforced gates can fail a verify; advisory gates report only.

FlagDefaultGates
quality_gate_mandatoryenforcedISO/Annex-A2 portrait quality; rejects bad frames.
pad_mandatoryadvisory*Presentation-attack detection. *Needs a trained PAD model wired to truly enforce.
require_attestationoffDevice/stream attestation (Play Integrity / App Attest).
IAD_AIGCD_MANDATORYadvisoryInjection-attack + AI-generated-content detection.
LICENSE_CERT_GATEoffSDK license cert required at verify; off = validate + log (additive rollout).

Errors

StatusMeaning
401Auth required / wrong demo password.
409Result token already redeemed (single-use).
422Validation — e.g. >30 frames, expired/duplicate nonce, bad ID number.
423Request locked — escalated for manual review.

Release notes

ComponentVersionNotes
Native SDK (facededup-live / FacededupLive)2.0.0-beta.60 Diagnostics beacon. The native SDKs now emit a small fire-and-forget telemetry beacon (flow_start, position_heal, action_timeout, flow_done / flow_error / flow_timeout) to the same gate-exempt /v1/clientlog sink the WebView flow uses — visible in the console Activity Log and CloudWatch. No PII, no images — stage, device model, engine (arkit/vision/mlkit), and counters only, so a stall can be diagnosed by phase and device instead of from a screenshot. On by default; set diagnostics: false to disable.
Native SDK (facededup-live / FacededupLive)2.0.0-beta.59 Camera self-heals when face detection is wedged. Fixes users getting stuck on “we can’t see your face yet” (common on smaller / non-Face-ID iPhones and some Android devices) that previously only cleared by cancelling and retrying several times. If the detector sees no face for ~3.5 s during positioning, the SDK now automatically restarts the camera pipeline (up to twice) — the automatic version of that cancel-and-retry. iOS also re-applies the capture orientation after the session goes live (a startup race left buffers sideways so Vision found nothing); Android now surfaces ML Kit detector failures instead of silently showing no face. Both platforms.
Native SDK (facededup-live / FacededupLive)2.0.0-beta.58 Wrong-direction feedback now covers look up / look down. Moving the opposite way on a head-turn already flashed the oval red; the same now applies to look up / look down — a clear tilt the wrong way turns the oval red, resets the progress arc and fires the error haptic. It is measured relative to the user's learned neutral pitch (not an absolute angle), so the camera-below-face angle can't false-fire, and a margin keeps natural head-bob from tripping it. Both platforms.
Native SDK (facededup-live / FacededupLive)2.0.0-beta.57 NINAuth footer pinned to the bottom of the screen (iOS) — it was tucked directly under the Cancel button; it now sits at the bottom, just above the home indicator, with more breathing room.
WebView SDK flow/demo2 New: Government-ID + Face demo. facededup.ai/demo2 boots straight into the ID-validation journey — country (54 African countries, auto-detected) → ID type (e.g. Nigeria: NIN / BVN number entry, or National ID / Passport / Driver's License / Voter's PVC live document scan) → face capture & match. Same underlying flow as /demo (zero code drift); /demo?flow=select still shows the full chooser menu.
WebView SDK flow (/demo)2026-07-13 Illumination no longer takes over the screen — and only runs in the dark. The active illumination check used to cover the whole screen with a colour wash, hiding the camera, oval and instructions (it read as a separate screen). It now renders behind the capture UI: the live preview, the face oval, the progress ring and the prompts all stay visible while the surrounding screen emits the colour — same-screen, uninterrupted, matching the native SDKs. It is also dark-gated: the flashes run only when the face actually reads as under-lit (luma < 110); a well-lit face skips the step entirely (skipped: well_lit, advisory), making the flow faster for most users. Deployed server-side — live for all WebView app versions immediately, no app update needed.
Liveness backendserver Colocation clustering — device-farm detection. The risk engine now tracks how many distinct subjects verify from the same source IP and from the same ~110 m geo cell within a rolling 1-hour window. Crossing the thresholds adds colocated_ip / colocated_geo risk reasons and raises the request's risk score — catching one operator running many identities from a single location, with no device scanning, no extra permissions, and no impact on genuine users (IP threshold sized for carrier CGNAT). Only hashed cluster keys and hashed subject ids are stored, auto-expiring hourly.

WebView SDK 1.3.13: instant page load (face engine defers to idle), the directional arc now matches the native SDKs (starts empty, fills with live progress, resets on a wrong move), and browser fraud signals added: num_cameras, webdriver/headless detection, battery, CPU cores, touch points.
WebView SDK (ng.facededup:facededup) + hosted flow1.3.11 Fast submit — the WebView flow no longer stalls after the challenge. The post-challenge chain (consent → device context → request → attestation bridge → upload → verify) ran fully serially while the user stared at the success mark. Now consent/request/device-context and the attestation bridge all run during the challenge; a slow attestation gets at most 1.5 s grace at submit (was a blocking 8 s timeout); and the upload is capped at 12 frames (~2.5× smaller — the portrait and every action-proof frame are always kept). The hosted flow (/demo, used by iOS wrappers and web) is already live; Android integrators get it via ng.facededup:facededup:1.3.11, whose bundled flow was also 20 days stale and is now synced.
Native SDK (facededup-live / FacededupLive)2.0.0-beta.56 NINAuth brand mark in the footer. A small, subtle NINAuth logo (keyhole badge + wordmark, drawn as vectors — crisp at any density) now sits beneath the Cancel button on the capture screen, both platforms.

Demo app: facededup-live-demo-2.0.0-beta.56-debug.apk
Native SDK (facededup-live / FacededupLive)2.0.0-beta.55 Faster success check — attestation no longer gates the submit. The device-attestation token fetch (Play Integrity / App Attest) ran in series before consent + request, so a slow attestation delayed the whole post-challenge submit — felt as a noticeable pause on the frozen selfie before the success check appeared. The token now mints in a parallel task for the whole challenge, and at submit a straggler gets at most 1.5 s of extra grace before the SDK proceeds without it (a missing token is an advisory server signal, exactly as before). Typical challenge-to-check latency is now just the verify round-trip.

Demo app: facededup-live-demo-2.0.0-beta.55-debug.apk
Native SDK (facededup-live / FacededupLive)2.0.0-beta.54 The camera never outlives the challenge. The moment the last proof frame is captured, the SDK stops the camera stream and freezes the captured selfie in the oval — previously the live camera kept showing through “Verifying…” and, on error results, the result screen, and the stream kept running until the host dismissed the SDK. Now: live preview only while the challenge runs; frozen selfie from submit onward; neutral placeholder (never a live feed) on any terminal screen without an image; the stream is also stopped on every error/timeout path. Privacy + user-trust fix; no behavioral change to capture or verification.

Demo app: facededup-live-demo-2.0.0-beta.54-debug.apk
Liveness backendserver Device attestation fully live on both platforms. Every verification now carries a hardware-attested verdict in the decision record (device_attestation check + attestation_verified): Android — Google Play Integrity decoded and policy-checked (session-nonce binding, package name, Play-recognized app, device integrity); iOS — Apple App Attest verified end-to-end (certificate chain to Apple's App Attestation Root CA, session-nonce binding, key binding, pinned Team ID + bundle ID). Tokens route automatically by platform. This closes the virtual-camera / injected-feed / repackaged-app attack class for attested sessions. Attestation is advisory by default (older app builds carry no token) and can be enforced per-tenant via require_attestation once fleet adoption allows.
Native SDK (facededup-live / FacededupLive)2.0.0-beta.53 The challenge is now dramatically easier for genuine users — with unchanged security. All leniency is on-device detection only; the server re-verifies every action with unchanged thresholds, and continuity, reaction-timing, device-motion and PAD model checks all still apply.
  • Adaptive difficulty: after a first miss, the same action re-runs with a wider window (7 s → 11 s) and a shallower look-up/down pitch (14° → 10°) — built for noisy budget-phone cameras.
  • Smart action swap: if one action keeps failing (3 misses), the SDK swaps in a different, easier action instead of looping into a full restart. Hard caps: one swap per session, never to an already-used action, and a head-turn can never be swapped down to blink/smile-only — so the challenge can't be "fished" down by an attacker. Timeout/swap counts are reported to the server for risk scoring.
  • Hand-tremor tolerance: the device-motion guard's soft thresholds are sized for a shaky hand on a light phone (sustained-instability window 1.2 s → 2 s); the hard deliberate phone-tilt trigger is unchanged.
  • Lighting guides instead of blocking: positioning no longer stalls forever in a dim room — only severe darkness blocks; marginal light proceeds with the full-brightness fill-light and the server's quality/PAD checks judging the frames.
  • Real coordinates: the SDK now requests one fresh GPS fix at flow start (concurrent, ~free) instead of relying on the OS cache that was usually empty — location is populated whenever the host app holds permission. Per-tenant challenge config is also cached (one fewer round-trip per session).
Demo app: facededup-live-demo-2.0.0-beta.53-debug.apk
Native SDK (facededup-live / FacededupLive)2.0.0-beta.53 Reliable point-of-capture coordinates + one fewer round-trip. The metadata location was usually null even with permission granted, because the SDK read only the OS's cached last-known fix (frequently empty). It now requests one fresh fix at flow start (short timeout, runs concurrently with the challenge so it adds no delay) and falls back to last-known. Still prompt-free — only fires when your app already holds location permission, so request it before launching the SDK. Also: the per-tenant challenge config is now cached across sessions (process-level, ~30 min) instead of re-fetched every launch — one fewer network call per verification.

Demo app: facededup-live-demo-2.0.0-beta.53-debug.apk
Liveness backendserver Fix: “no active consent” cross-instance failures. Consent records were held only in the memory of the server instance that granted them, so a /v1/request served by a different instance failed with no active consent for biometric capture — ~28% of started flows died between consent and request. Consents are now persisted to the shared state store and rehydrated anywhere. No SDK change needed; retries now succeed.

Device attestation enabled — both platforms. Android: Play Integrity tokens are decoded and verified (nonce ↔ challenge binding, package name, Play-recognized app, device integrity). iOS: App Attest attestation objects are now fully verified server-side — certificate chain to Apple's App Attestation Root CA, nonce binding (SHA256(authData ‖ SHA256(nonce)), anti-replay), key binding (keyId = SHA256(attested public key)), and app binding (Team ID + bundle ID pinned server-side). Tokens route automatically by platform; attestation_verified scores into every decision. Attestation is advisory by default and can be enforced per-tenant.
Native SDK (facededup-live / FacededupLive)2.0.0-beta.52 Startup resilience — no more instant first-trial failures. The consent/request/attestation prefetch is now isolated: an early network blip can no longer cancel the whole flow before the challenge starts (it retries once at submit instead), and the Play Integrity / App Attest call is time-boxed to 8 s so a stalled attestation task can no longer hang the session into the 90 s watchdog (“Timed out” with no server contact). A missing attestation token stays a server-side scoring signal, exactly as before.

Demo app: facededup-live-demo-2.0.0-beta.52-debug.apk
Native SDK (facededup-live / FacededupLive)2.0.0-beta.52 Fix: session no longer dies on a startup network blip or a stalled attestation. Two flow bugs found while verifying field reports (“fails instantly with no challenge” / “fails after the challenge with no success check”): (1) on Android, an error in the background consent/request prefetch cancelled the whole flow immediately — a brief network blip at start killed the session before the challenge began; (2) a Play Integrity / App Attest call that never returned (common on sideloaded/debug installs) held the prefetch hostage until the 90 s watchdog — the challenge completed but the session timed out with no server contact. Now: the prefetch is isolated (a failure surfaces at submit and is retried once — connectivity is usually back by then), and attestation is time-boxed to 8 s (a missing token is already a server-side scoring decision, not a hard failure). Both platforms.

Demo app: facededup-live-demo-2.0.0-beta.52-debug.apk
Native SDK (facededup-live / FacededupLive)2.0.0-beta.51 Point-of-capture coordinates in the metadata. The result metadata’s location block (and the decision record) now carries real GPS coordinates — {lat, lng, accuracy_m} — captured at submit time and sent as gps_lat/gps_lon/gps_accuracy_m. Best-effort and prompt-free: the SDK never asks the user for location — it reads the freshest cached fix only when your app already holds a location permission (Android fine/coarse via plain LocationManager, works on non-GMS devices; iOS CLLocationManager when-in-use/always). No permission → location: null, as before. To get coordinates, request location permission in your app before launching the SDK.

Demo app: facededup-live-demo-2.0.0-beta.51-debug.apk (Android, debug-signed) — try the full flow on-device without integrating.
Native SDK (facededup-live / FacededupLive)2.0.0-beta.50 Screen stays awake + full backlight through the whole challenge. The user never touches the screen mid-challenge (they move their head), so on phones with a short screen-timeout the OS dimmed and then blanked the display — the face fill-light died and detection collapsed (“can't pass the challenge”). The SDK now holds the screen awake for the whole flow (Android FLAG_KEEP_SCREEN_ON, iOS idle-timer disabled) and forces 100% brightness from Hold Still through submit; adaptive brightness runs only while positioning. The user's original brightness and idle-timer settings are always restored on exit. Also includes the server-fetched per-tenant challenge configuration (GET /v1/sdk/challenge-config).

Smaller result payloads — echoed frames recompressed. The liveliness frames echoed back to your app are now recompressed to echoFrameSpec (default 360×480 q60, ~20 KB each — result payload down ~5–8×). The server still receives the full actionSpec quality for verification, and the selfie is never recompressed. Set echoFrameSpec = null to echo full-quality frames. Integration warning: never place the SDK result in an Android Intent extra, Fragment arguments or onSaveInstanceState — Binder parcels are capped at ~1 MB and oversized saved state crashes with TransactionTooLargeException when the activity stops. Keep the result in a ViewModel/singleton or write the images to a file and pass the path.
Liveness backendserver Fewer false rejections on budget Android. When a tenant runs with the active-challenge gate relaxed, a capture with a high passive PAD score (a genuine 3D face — a screen/print scores low here) is now accepted as live even if the frame-motion engine reads a little under the liveness cutoff and detects no explicit head motion. This clears genuine low-end Android users who previously landed in the not_live 0.49 band. Deepfake / anti-spoof / passive-fail model gates still veto real attacks. Server-side, per-tenant — no SDK update required.
Native SDK (facededup-live / FacededupLive)2.0.0-beta.49 Fix: full brightness during active illumination. Adaptive brightness (beta.47) could leave the screen dim during the illumination flashes, so the face didn't visibly respond and the server flagged illumination_no_response → replay → not_live 0.49 (Android; iOS TrueDepth unaffected). The screen now snaps to 100% during the illumination phase, then adaptive resumes.
Native SDK (facededup-live / FacededupLive)2.0.0-beta.48 Response encryption ON by default. security now defaults to an enabled SecurityConfig() — every session encrypts out of the box (no security block needed). encryptedPayload is populated by default; subjectId/requestId move into the token. To opt OUT, pass security: nil / security = null (or responseEncryption: false).
Native SDK (facededup-live / FacededupLive)2.0.0-beta.47 Intelligent adaptive screen brightness. Instead of always forcing 100%, the SDK measures face luminance and raises the screen (front fill-light) only when it improves face illumination: dark face → smooth raise (stops once well-lit); already well-lit / outdoor sun → leave unchanged (a raise that doesn't lift the face is abandoned, so it never fights the sun or adds glare); overexposed → ease back + guidance (“Move to softer lighting”). Smooth ~350 ms ramp, hysteresis (no flicker/oscillation); the user's original brightness is always restored on exit (success / fail / cancel / interrupt). Both platforms.
Native SDK (facededup-live / FacededupLive)2.0.0-beta.46 Token encrypts metadata only (images excluded). The encrypted token no longer contains the base64 images — only the metadata (ids, verdict, device/network/location). Images ride back in cleartext (selfieImageB64/frameImagesB64) for display, so the token is small and fast. (beta.45: demos log the token length/preview + write it to a file, since Logcat/NSLog truncate the long line.)
Native SDK (facededup-live / FacededupLive)2.0.0-beta.44 Encryption mode returns images + token only. With responseEncryption on, the result now exposes only the base64 images + encryptedPayload (+ outcome/ errorCode for flow). All other plaintext — subjectId, requestId, verdict, metadata — is dropped and lives solely inside the encrypted token.
Native SDK (facededup-live / FacededupLive)2.0.0-beta.43 Base64 images + encrypted token together. With responseEncryption on, the result now returns selfieImageB64 + frameImagesB64 (cleartext, for in-app display) alongside encryptedPayload (the opaque token to forward to your backend) — verdict/metadata stay sealed in the token. New console Decryption kit (Tools): download the offline decrypt tool + instructions; the private key stays in SSM, never served.
Native SDK (facededup-live / FacededupLive)2.0.0-beta.42 Encryption hardening + UX. encryptedPayload is now a single opaque token (one base64 blob — no field names, no algorithm string, no readable keyId; the backend infers the algorithm from the key). Master key is RSA-4096 held in SSM Parameter Store (SecureString, KMS-decrypted) — env-var keys are too small for RSA-4096; rotation re-puts the SSM param. Decrypt endpoints + console Decrypt result accept the bare token (and still the JSON envelope, back-compat). Both demo apps ship with responseEncryption on. The on-screen “Outcome …” line was removed from the success view (just the check + dots). See Enterprise response encryption.
Native SDK (facededup-live / FacededupLive)2.0.0-beta.36 Enterprise response encryption (public-key v2). The SDK encrypts its final result to a BACKEND-owned public key and returns only ciphertext — it holds no private key, no decryption, no long-term secret, so reverse-engineering the app yields nothing. Hybrid AES-256-GCM + RSA-4096 (EC also supported). New SecurityConfig (responseEncryption, REMOTE / EMBEDDED / INTEGRATOR key sources, keyId rotation, on-device cache, optional per-session keys). New backend endpoints GET /v1/sdk/public-key, POST /v1/sdk/session, POST /v1/sdk/decrypt; envelope carries integrity meta (timestamp / nonce / sessionId / sdkVersion) and the backend rejects tampered / replayed / expired / unknown-key payloads. See Enterprise response encryption.
Native SDK (facededup-live / FacededupLive)2.0.0-beta.35 Device-motion guard — anti phone-tilt / shake. Head-movement challenges must be satisfied by genuine head movement, not by tilting or moving the phone. MotionMonitor now fuses gyro rate + linear acceleration + attitude tilt (CoreMotion userAcceleration / Android TYPE_LINEAR_ACCELERATION) into a stable / soft / hard verdict vs an action baseline. Minor device movement pauses progress and decays the arc (it can't be used as a feedback tool) with a firm visible warning ("Keep your phone steady"); Hold Still now requires a stable phone before the challenge starts. Violations escalate per session: 1st warn → 2nd restart to Hold Still → 3rd fail with new error code device_motion. See Security you get for free.
Native SDK (facededup-live / FacededupLive)2.0.0-beta.34 SDK licensing + encrypted results. Offline-verified per-tenant license cert (FacededupConfig.licenseCert) + result encrypted to your key (encryptedPayload; RSA-OAEP or ECIES) with an encryptedResultOnly mode that returns nothing in cleartext.
Client anti-fraud layer: RASP (root/jailbreak/debugger/emulator/hooks), virtual-camera + camera-source checks, replay/loop frame_integrity, secure capture (FLAG_SECURE / screen-capture report) — all auto-collected, server-scored.
look_up / look_down re-enabled in the challenge pool — verified by ADAPTIVE pitch on-device (relative to the user's learned neutral) and RELATIVELY on the server (vs the neutral-frame pitch baseline), so the camera-below-face angle offset no longer causes false referred. All six challenges now active: turn_left · turn_right · look_up · look_down · blink · smile.
Live directional progress arc. An empty track appears only on the side the user must move toward (left/right/up/down) and fills GREEN in real time as valid movement is detected (resets on a wrong move), tracked by a critically-damped spring for smooth ~60fps fill; a soft green success pulse blooms outward on each completed challenge (turn/blink/smile). Smile & blink fixed at the root — adaptive per-user baselines (relative change) replace the fixed probability cutoffs ML Kit/Vision never reliably hit. Arc spaced further off the oval.
Result API carries the captured images. onResult delivers a typed FacededupResult (outcome, isLive/passed, score, selfieImageB64, frameImagesB64, errorCode, rawJson) on both platforms — no more outcome-only string. Stable FacededupError codes (network_error · server_error · camera_unavailable · timeout · cancelled · unknown_error). iOS: XCFramework slices now embed a valid Info.plist (fixes "did not contain an Info.plist" on Embed & Sign). Capture: 720p ImageAnalysis for sharp frames (clears the ISO sharpness floor that was forcing referred). Borderless oval with a single soft breathing glow + success glow. 90 s watchdog guarantees a terminal result. Native CameraX+ML Kit / AVFoundation+Vision, no WebView.
Android SDK (ng.facededup:facededup)1.3.13 Continuous single-face presence lock, active illumination (random colour-flash reflection — anti screen/photo replay), 720p selfie capture, screen brightness + native haptics, faster Hold Still (650 ms) + snappier restart, device/network/location echoed in result, quality-gated frames. Per-tenant liveness policy — strictness (incl. off), verification mode (passive / multi-frame), and optional result encryption to the tenant's key. ~90s watchdog guarantees exactly one terminal result — a verify failure delivers outcome:"error". Drop-in WebView flow, bundled offline MediaPipe engine, minSdk 21. Public S3 Maven repo (no GitHub token).
Sample app (APK)1.3.13 Latest hosted flow; faster Hold Still + restart, presence lock, brightness, haptics, reliable action capture, device/network/location in result.
iOS SDK (FacededupLiveness)1.3.11 Same capture behaviour (presence lock, brightness, haptics, quality-gated frames, outcome:"error" on failure) plus iOS-tuned Apple-Vision detection: forward-hold pitch calibration (fixes "Face forward"), per-face smile calibration, first-try blink/smile, look up/down/left/right. Network-load hardening (1.3.9): the live hosted flow is retried up to 3× with backoff before the compiled-in offline fallback is used, so a flaky network never strands the user on a stale bundled copy. Binary xcframework (device + simulator) on the public S3 bucket — no GitHub token. Managed WKWebView, iOS 14.3+. Rebuild against 1.3.11 to pick up these fixes.
REST APIv1Consent → request → challenge → verify; identity, document, address, face products; Ed25519 signed results.

Facededup · API v1 · this page is static and safe to share.