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
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. blink → turn left → smile).
- 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 face check runs on-device, so capture works offline. 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
The hosted demo and API are currently protected by a single demo password (a login-cookie for browsers, HTTP Basic for native apps). Production deployments move to per-tenant API keys.
| Caller | How |
|---|---|
| Browser / WebView | Login page sets an HMAC cookie (sw_demo); sent automatically thereafter. |
| Native app / server | HTTP Basic — any username, the demo password. The SDK does this for you. |
curl -u :YOUR_PASSWORD https://facededup.ai/v1/security/healthz and this /docs page need no auth.
Everything else (/v1/*, /demo, /console) is gated.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 24.
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:0.6.0") } // 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-0.6.0.aar // 2) Drop it in your app module's libs/ folder, then: dependencies { implementation(files("libs/facededup-0.6.0.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 24. 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 — for a bank app, start STRAIGHT at the camera (no menu) verify.launch(FacededupConfig( baseUrl = "https://facededup.ai", password = "…", // demo gate; swap for API key/session token in prod subjectId = "user-123", flow = "liveness", // camera-only liveness; omit/"select" to show the menu showSettings = false, // hide the in-flow tester panel in production )) // 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)
| Type | Field | Notes |
|---|---|---|
FacededupConfig | baseUrl | Your Facededup host. |
password | Demo gate password (null once you issue API keys). | |
subjectId | Your user id, echoed back in the result. | |
flow | "liveness" = camera-only (no menu) · "enroll" · "authenticate" · "address" · null/"select" = 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). | |
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
Every result carries the captured frames — one selfie_image plus 4–8
liveliness_images — shaped for a downstream face-compare engine:
{
"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>" },
{ "image_type": "image_type_2", "image": "<BASE64_FRAME_3>" },
{ "image_type": "image_type_2", "image": "<BASE64_FRAME_4>" }
]
}
An enroll result is the same shape with "type":"enroll" and an
"enrollment_id" (e.g. FE-3A9B1B2260) instead of a score.
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:0.6.0")
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:0.6.0 |
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 15+. No WebView,
permission, auth, or bridge plumbing.
Step 1 — add the xcframework
// 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-0.5.0.xcframework.zip", checksum: "700b13242960f45def6beca31547533fe67780a762ecb44b343ef8dffcf1a55c" ), .target(name: "YourApp", dependencies: ["FacededupLiveness"]), ]
// 1) Download + unzip (no auth): // https://swiftend-assets-348761024048.s3.eu-west-2.amazonaws.com/sdk/ios/FacededupLiveness-0.5.0.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")!, password: "…", // demo gate; swap for API key/session token in prod 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). |
password | Demo gate password (nil once you issue API keys). | |
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
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.
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" }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: blink · smile · turn_left · turn_right · look_up · look_down.
{ "request_id": "rq_77f0" }
{
"session_id": "se_4d2",
"nonce": "Yk3…q9",
"actions": ["blink", "turn_left", "smile"],
"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": "blink" },
{ "image_b64": "<jpeg>", "proves_action": null }
],
"audio_present": false
}
{
"decision_id": "dc_9a1",
"outcome": "live", // live · referred · not_live
"score": 0.95,
"threshold": 0.6,
"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.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.
{ "selfie_b64": "<jpeg>", "subject_id": "user-123", "country": "NG" }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 redeemedSecurity 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. |
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 |
|---|---|---|
Android SDK (ng.facededup:facededup) | 0.5.0 |
Drop-in WebView flow, bundled offline MediaPipe engine, minSdk 24. Hosted on the public S3 Maven repo (no GitHub token). |
| Sample app (APK) | 0.6.0 |
Offline engine + latest web flow; time breakdown, address capture, friendly error codes on result screens. |
iOS SDK (FacededupLiveness) | 0.5.0 |
Binary xcframework (device + simulator) on the public S3 bucket — no GitHub token. Managed WKWebView, iOS 15+. |
| 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.