● Facededup Identity

Developer documentation

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

https://facededup.ai

Overview

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

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

The mobile SDK runs all of this inside a managed WebView and hands you a typed result — you don't touch the individual endpoints. The 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.

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

Products

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

Face Liveness

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

Biometric KYC

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

Identity Lookup

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

Document Verification

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

Address Verification

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

Face Enrollment & Dedup

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

Authentication

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.

CallerHow
Browser / WebViewLogin page sets an HMAC cookie (sw_demo); sent automatically thereafter.
Native app / serverHTTP Basic — any username, the demo password. The SDK does this for you.
curl -u :YOUR_PASSWORD https://facededup.ai/v1/security
Open endpoints: /healthz and this /docs page need no auth. Everything else (/v1/*, /demo, /console) is gated.

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.

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

Step 1 — add the repository + dependency

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

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

// app/build.gradle.kts
dependencies {
    implementation("ng.facededup:facededup: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)
TypeFieldNotes
FacededupConfigbaseUrlYour Facededup host.
passwordDemo gate password (null once you issue API keys).
subjectIdYour 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".
agentModetrue = rear camera (agent points it at the customer).
showSettingsShow the in-flow tester panel (default off in prod).
productName / primaryColorBranding — title + brand colour (hex).
FacededupResultpassedtrue for a clear pass — check this.
outcome / score / decisionlive · referred · not_live, 0–1 score.
enrollmentId / rawEnrollment id (if any) + full JSON payload.
selfieImageFacededupImage? — best selfie (imageType + base64 image).
livelinessImagesList<FacededupImage> — 4–8 liveness frames.

Sample result

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

SymptomFix
Unresolved reference: FacededupConfig The dependency isn't on the classpath. Confirm the maven{} repo is in settings.gradle.kts (not the module), the implementation("ng.facededup:facededup: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.
Direct artifact links (no auth): facededup-0.6.0.aar · POM

iOS SDK (xcframework)

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

Hosted publicly — no GitHub access or token needed. The signed xcframework (device + simulator slices) is on the same public S3 bucket as the Android SDK. Add it as a binary Swift-package target, or just drag it into your project.

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.
Add NSCameraUsageDescription (and NSMicrophoneUsageDescription for voice methods) to your app's Info.plist.

Step 2 — present the flow

import FacededupLiveness

let vc = FacededupVerificationController(config: .init(
    baseURL: URL(string: "https://facededup.ai")!,
    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)
TypeMemberNotes
FacededupConfigbaseURLYour Facededup host (a URL).
passwordDemo gate password (nil once you issue API keys).
subjectIdYour user id, echoed back in the result.
FacededupResultpassedtrue for a clear pass — check this.
outcome / score / decision / enrollmentId / rawlive · referred · not_live, 0–1 score, full payload.

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

Web embed

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.

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

Two ways to run offline

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

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

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

Customization

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

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

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

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

Request

POST/v1/request

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

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

Challenge

POST/v1/challenge

Returns a randomized, time-boxed active-liveness challenge (3 actions, 20s TTL, nonce-protected). Action pool: 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

POST/v1/verify

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

{
  "request_id": "rq_77f0",
  "session_id": "se_4d2",
  "nonce": "Yk3…q9",
  "frames": [
    { "image_b64": "<jpeg>", "proves_action": "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 } ]
}
Decision band: score ≥ 0.6 → live; within 0.07 below → referred; lower → not_live. Carry the result_token into the identity step to bind the match to a proven-live face.

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

POST/v1/identity/verify

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

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

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

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

Document — parse MRZ

POST/v1/document/mrz

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

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

Document — verify + face match

POST/v1/document/verify

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

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

Address verify — geocode + GPS match

POST/v1/address/verify

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

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

Face enroll

POST/v1/face/enroll

Enroll a live selfie and receive an enrollment id (FE-…) for later 1:N search.

{ "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.

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

Security switchboard

GET/v1/security
PUT/v1/security

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

FlagDefaultGates
quality_gate_mandatoryenforcedISO/Annex-A2 portrait quality; rejects bad frames.
pad_mandatoryadvisory*Presentation-attack detection. *Needs a trained PAD model wired to truly enforce.
require_attestationoffDevice/stream attestation (Play Integrity / App Attest).
IAD_AIGCD_MANDATORYadvisoryInjection-attack + AI-generated-content detection.

Errors

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

Release notes

ComponentVersionNotes
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 APIv1Consent → request → challenge → verify; identity, document, address, face products; Ed25519 signed results.

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