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
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:
- Consent — record the subject's explicit consent (NDPA lawful basis).
- Request — open a verification request bound to that consent.
- Challenge — get a randomized active-liveness challenge (e.g. turn left → look up → turn right).
- Verify — submit the captured frames; receive a signed
live / referred / not_liveresult. - 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.
Quickstart
From zero to a verified user in five steps — the same path whether you ship the mobile SDK or call the REST API.
- Get access. Ask us for your Facededup host + demo password (or, in production, a per-tenant API key). See Authentication.
- Choose a product. Liveness only? Liveness + NIN/BVN? Document + face? Pick from Products below — each one tells you the method and endpoint to use.
- Choose an integration. Drop-in Android / iOS SDK, a web embed, or the REST API for custom capture / server-to-server.
- Run your first verification. Launch the SDK (or POST consent → request → challenge → verify) and
read the signed
live / referred / not_liveresult. Test it offline too — see Offline mode. - 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.
| Integration | Best for | What 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 embed | Web apps | Open the hosted /demo/ flow; receive the result via a JS bridge / redirect. |
| REST API | Servers, custom capture | Drive 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
live / referred / not_live.⛉ Biometric KYC
⌕ Identity Lookup
▤ Document Verification
⌖ Address Verification
match / refer / no_match.+ Face Enrollment & Dedup
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.
| Caller | How |
|---|---|
| SDK / server (prod) | X-License-Key: fdk_… — your tenant key; the SDK sends it for you. |
| Browser demo | Login page sets an HMAC cookie (sw_demo) from the shared demo password. |
curl -u :YOUR_PASSWORD https://facededup.ai/v1/security/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.
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.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.
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 FacededupResult
— the 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):
| Field | Type | Notes |
|---|---|---|
outcome | String | live · not_live · referred · captured (echo) · error |
isLive / passed | Boolean | passed == (outcome == "live") — gate on this. |
score | Int? | 0..100 server score; null in echo/error. |
selfieImageB64 | String? | Best selfie, raw base64 JPEG. |
frameImagesB64 | List<String> | Liveness frames, raw base64 JPEG. |
errorCode | String? | Null on success. See codes below. |
rawJson | String? | Full /v1/verify payload — incl. assurance, truedepth, session_signature. |
payloadJson use this | String? | 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. |
encryptedPayload | String? | 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. |
subjectId | String? | 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. |
requestId | String? | 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.
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 block + the verify payload:
| Signal | Catches |
|---|---|
RASP — is_rooted_jailbroken · is_debugger_attached · is_emulator · hook/Frida detection | Tampered devices, instrumentation, emulators |
Camera-source — num_cameras + camera_source (physical front vs external/continuity/virtual) | Virtual-camera / injected video feeds |
Frame-freshness — frame_integrity (per-frame dHash duplicate/loop detection) | Replayed or looped frame sets |
Secure capture — Android FLAG_SECURE (blocks screenshot/record/cast) · iOS screen_captured report | Recording the flow for later replay |
| Attestation + app integrity — Play Integrity / App Attest token · signing-cert hash | Repackaged SDK, non-genuine app |
| Device-motion guard beta.35 — CoreMotion / SensorManager: gyro rate + linear acceleration + attitude tilt vs an action baseline | Tilting / shaking / moving the phone to fake a head-movement challenge |
- 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.
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.
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)
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.)| Type | Field | Notes |
|---|---|---|
FacededupConfig | baseUrl | Your Facededup host. |
licenseKey | Per-tenant license key (fdk_…) — the production auth. Sent as X-License-Key. | |
password | Demo gate password (omit once you use a license key). | |
subjectId | Your user id, echoed back in the result. | |
flow | Default "liveness" = camera-only, no menu · "enroll" · "authenticate" · "address" · "select" = show menu. | |
method | "face_liveness" (motion) · "face_number" (read digits aloud). | |
strictness | "lenient" · "standard" · "strict". | |
agentMode | true = rear camera (agent points it at the customer). | |
showSettings | Show the in-flow tester panel (default off in prod). | |
showBack | Show the in-flow back button — default off (SDK has no menu to return to). | |
productName / primaryColor | Branding — title + brand colour (hex). | |
FacededupResult | passed | true for a clear pass — check this. |
outcome / score / decision | live · referred · not_live, 0–1 score. | |
enrollmentId / raw | Enrollment id (if any) + full JSON payload. | |
selfieImage | FacededupImage? — best selfie (imageType + base64 image). | |
livelinessImages | List<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.
outcome. The SDK delivers exactly one
terminal result and the host screen closes on it. Possible values:
outcome | Meaning · what to do |
|---|---|
live | Liveness passed (is_live: true). Proceed. |
not_live | Failed liveness / spoof suspected. Reject or re-prompt. |
referred | Sent for manual review. Show "under review". |
error | Verify could not complete (server/timeout/offline/low-quality). Carries
error + error_code ("verify_failed" | "offline").
Re-launch the SDK to retry. |
queued | Offline capture stored on-device; verdict arrives later by webhook (Offline mode). |
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)
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 |
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
Applies when you opt into the verdict in the app
(returnImagesOnly = false). By default the verdict lives on your
dashboard, not the app.
- 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. - Forward
payloadJsonverbatim to your backend. It's the one stable envelope (same on every outcome). Persist it; never trust a verdict computed on the client. - Handle all five outcomes —
live·not_live·referred·error·queued. Re-launch the SDK onerror; show "under review" onreferred.
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
| Symptom | Fix |
|---|---|
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. |
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.
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
FacededupResult — the 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 viaFacededupConfig (branding, text, capture, submitToServer).
The xcframework below remains the stable default; the native SDK is alpha.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.
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)
| Type | Member | Notes |
|---|---|---|
FacededupConfig | baseURL | Your Facededup host (a URL). |
licenseKey | Per-tenant license key (fdk_…) — the production auth. Sent as X-License-Key. | |
password | Demo gate password (nil once you use a license key). | |
subjectId | Your user id, echoed back in the result. | |
FacededupResult | passed | true for a clear pass — check this. |
outcome / score / decision / enrollmentId / raw | live · 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.
| URL | Opens on | Use 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 & match | KYC / 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=select | The 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.
| Option | Values | What it does |
|---|---|---|
flow | liveness (default) · validate · enroll · authenticate · address · select | Which journey to run. liveness = straight to camera; validate = Government-ID + Face (the /demo2 default); select = show the chooser menu. |
method | face_liveness (default) · face_number | Liveness challenge type: motion (turn head to prompts) or read-numbers-aloud (server issues digits, mic STT + audio anti-spoof while the face is captured). |
strictness | lenient (default) · standard · strict | How hard the check is to pass. strict requires a reset to centre between actions; hardest to game. |
license | fdk_… | Per-tenant license key (production auth). Also marks the launch as a real tenant (hides the tester Settings panel). See Authentication. |
subject | string | Your user identifier, echoed back on the result so you can tie the captured images to a user. |
agent | 1 / 0 | Agent mode — uses the rear camera (an operator points the phone at the customer). |
color | hex, URL-encoded (%230a3d62) | Primary brand colour (drives the accent, ring, buttons). |
product | string | Product name shown in the header. |
settings | 1 / 0 | Show/hide the tester Settings panel (auto-hidden when a license is set). |
back | 1 / 0 | Show/hide the in-flow back button (SDK launches set 0). |
apiBase | URL | Override the API origin (defaults to the page's own origin). Rarely needed. |
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.)
0.1.1import { 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.
| Export | Use |
|---|---|
LivenessClient | new 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). |
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.
| Platform | Package | Call |
|---|---|---|
| React Native | @facededup/liveness-react-native | await startLiveness({ baseUrl, subjectId, licenseCert }) |
| Flutter | facededup_liveness | await 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 }
// pubspec: facededup_liveness ; cd ios && pod install import 'package:facededup_liveness/facededup_liveness.dart'; final r = await FacededupLiveness.start(LivenessConfig( baseUrl: 'https://facededup.ai', subjectId: 'user-123', licenseCert: '…', )); if (r.outcome == 'captured' || r.isLive) { // upload r.selfieImage / r.livelinessImages / r.encryptedPayload }
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.
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.
| Product / step | Capture & on-device check | Final scoring / lookup |
|---|---|---|
| Face Liveness | Offline ✓ challenge, landmarks, quality | Online server liveness score |
| Document Verification | Offline ✓ MRZ scan, selfie capture | Online MRZ check digits + face match |
| Face Enrollment | Offline ✓ selfie capture + quality | Online gallery enroll |
| Biometric KYC (NIN/BVN) | Offline ✓ liveness capture | Online required registry 1:1 |
| Address Verification | Offline ✓ GPS capture | Online 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": "…" }
Customization
Tune the flow without forking the SDK — pass options on FacededupConfig (mobile) or as
query params on the /demo/ embed (web).
| Option | Effect |
|---|---|
method | face_liveness · face_voice · face_number · assisted — which capture flow to run. |
risk_level | baseline or stepup — step-up adds extra challenge actions. |
subjectId | Your user id, echoed back in the result and shown in the console. |
tenant_id | Routes decisions to the right tenant queue in the console. |
offlineMode | Capture & store without submitting (see Offline mode). |
| Theme / brand | Accent colour + logo follow your tenant config; the flow inherits it automatically. |
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).
| Where | Fields |
|---|---|
POST /v1/consent | subject_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.device | platform, 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.network | connection_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 / attestation | client_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
Open a verification request bound to a consent. method ∈
face_liveness · face_voice · face_number · assisted; risk_level ∈ baseline · 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
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
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 } ]
}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.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 pad → not_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.
| Level | Pass cut | Refer band | Deepfake + anti-spoof | Passive-PAD floor | Active challenge |
|---|---|---|---|---|---|
| Lenient | ≥ 50% | ±5% | off | off | required |
| Standard (default) | ≥ 60% | ±7% | off | on | required |
| Strict | ≥ 70% | ±10% | on | on | required |
| Off | — | — | off | off | not required |
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.{ "strictness": "lenient|standard|strict|off" } (super-admins target a tenant with ?tenant=)Identity verify — Face + NIN/BVN (1:1)
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
}Identity search — Face-only (1:N)
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
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
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
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": "…"
}ADDRESS_GEOCODER_URL (Google / Mapbox / Nominatim
shapes auto-detected). Until then a mock provider returns refer — it never fabricates a match.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 }
}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
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
}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.
{ "token": "<result_token>" } // 200 = valid & first use; 409 = already redeemedLicensing & 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.
- We mint you a cert (
POST /v1/license/issue) bound to yourtenant_id, your app's bundle id / package name, an expiry, and your encryption public key. - You pass it to the SDK as
FacededupConfig.licenseCert. The SDK verifies it locally (ES256 signature + expiry + bundle) before any capture — invalid → alicense_*error. - 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
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.
| Config | Default | Effect |
|---|---|---|
returnImagesOnly | true default | App 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. |
encryptedResultOnly | true | With a cert → encrypted-only. Set false to ALSO get the cleartext (debugging). |
requireLicense | false | Set true to forbid any cleartext — no/invalid cert fails fast (license_missing). |
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:
key_id (takes effect at the next verify){ "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.
Public-key sources & rotation
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)
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)
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
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
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
Inspect or toggle the enforcement gates. enforced gates can fail a verify; advisory gates report only.
| Flag | Default | Gates |
|---|---|---|
quality_gate_mandatory | enforced | ISO/Annex-A2 portrait quality; rejects bad frames. |
pad_mandatory | advisory* | Presentation-attack detection. *Needs a trained PAD model wired to truly enforce. |
require_attestation | off | Device/stream attestation (Play Integrity / App Attest). |
IAD_AIGCD_MANDATORY | advisory | Injection-attack + AI-generated-content detection. |
LICENSE_CERT_GATE | off | SDK license cert required at verify; off = validate + log (additive rollout). |
Errors
| Status | Meaning |
|---|---|
401 | Auth required / wrong demo password. |
409 | Result token already redeemed (single-use). |
422 | Validation — e.g. >30 frames, expired/duplicate nonce, bad ID number. |
423 | Request locked — escalated for manual review. |
Release notes
| Component | Version | Notes |
|---|---|---|
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 backend | server |
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 flow | 1.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 backend | server |
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.
|
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 backend | server |
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 backend | server |
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 API | v1 | Consent → request → challenge → verify; identity, document, address, face products; Ed25519 signed results. |
Facededup · API v1 · this page is static and safe to share.