apenia_verify 0.0.17
apenia_verify: ^0.0.17 copied to clipboard
Apenia Verify — KYC capture SDK. Native active-liveness selfie + document scan; matching runs server-side in Apenia Verify.
apenia_verify #
Flutter KYC capture SDK for Apenia Verify.
The handset captures — native active-liveness selfie (ML Kit on Android, Vision on iOS) and document scan (CameraX / AVFoundation). The matching engine lives server-side in Apenia Verify: face-match, liveness/PAD scoring and any ID-authority lookup all happen there. The device never decides pass/fail and never holds the secret key.
This mirrors the ergonomics of the smile_id package (drop-in product
screens, PartnerParams, create-session → upload → callback) but keeps the
verification intelligence on your own infrastructure.
Capture UX #
- Document — fixed enclosure, no enhancement. A contained (non-fullscreen)
camera with a document-shaped frame sized to the physical doc (ID-1 card
1.586, passport data page1.42). Built on plain CameraX / AVFoundation — not the OS document scanner — so there are no enhancement, filter or re-crop options. The saved image is cropped to exactly the frame. Front, then back for two-sided documents. - Confirm / retake. After each side: "Is the document clear and readable?" → Yes, use this / Retake photo.
- Auto or manual capture.
ApeniaConfig.documentCaptureModeselectsmanual(shutter, default) orauto(fires once the document is detected in frame). The shutter always works as a manual override in auto mode. - On-device detail extraction. After the MRZ-bearing side (ID back / passport page) the SDK OCRs it on-device — ML Kit on Android, Vision on iOS — and parses the MRZ (TD1 / TD3) into full name, ID number, date of birth, sex, nationality and expiry. Kenyan IDs yield both the serial number and the national ID number, in that order.
- Face — controlled face-scanner. A dimmed oval overlay guides the user; capture won't start until the face is centred, correctly sized, frontal and eyes-open (the ring turns green). Then the active-liveness challenges run.
- 100% live data — no static path. The selfie primary and every liveness frame are sampled only during the live session, and the capture fails unless the full challenge sequence completes with a face present throughout. If the face is lost mid-sequence, positioning restarts. There is no code path that returns a still or gallery image, so what's uploaded is always live capture.
Liveness honesty: the free on-device path is active liveness — it proves a live person is present and following prompts (blink / turn / smile). It is not ISO/IEC 30107-3 presentation-attack detection. Apenia Verify runs passive PAD scoring server-side on the uploaded frames. If you later need certified on-device passive PAD, swap the native capture module for a licensed vendor SDK (FaceTec / iProov / Regula) behind the same
ApeniaVerifyPlatforminterface — the Dart API and upload flow don't change.
Install #
flutter pub add apenia_verify
or in pubspec.yaml:
dependencies:
apenia_verify: ^0.0.17
Android #
minSdk≥ 24. TheCAMERApermission and capture activities are declared in the plugin manifest and merged automatically.- Requires Google Play Services. Face detection and document OCR use the
unbundled ML Kit variants, so the models are served by Play Services rather
than shipped in your APK — this keeps ~22 MB out of the build. The plugin
manifest requests the
faceandocrmodels at install time; on a device without Play Services (or with the models not yet downloaded), the liveness and MRZ steps will not run.
iOS #
- Deployment target ≥ 13.0.
- Add to the host app's
Info.plist:<key>NSCameraUsageDescription</key> <string>Used to capture your selfie and ID document for verification.</string>
Usage #
await ApeniaVerify.initialize(ApeniaConfig(
appKey: 'pk_…', // publishable — safe on device
sessionEndpoint: 'https://your.app/api/verification/session',
authTokenProvider: () async => auth.jwt, // the signed-in user's token
));
The SDK POSTs {flow, metadata} to your endpoint with that bearer token and
expects {session_id, upload_url, client_token} back (bare or wrapped in a
data envelope). Your backend is what holds the Apenia secret sk_….
If you need non-bearer auth, extra headers or a session minted some other way,
use sessionProvider instead:
await ApeniaVerify.initialize(ApeniaConfig(
appKey: 'pk_…',
sessionProvider: (req) async {
final json = await myBackend.createSession(flow: req.flow, metadata: req.params.toJson());
return ApeniaSession.fromJson(json);
},
));
// Drop-in product screen:
ApeniaBiometricKYC(
params: PartnerParams(userId: user.id, jobId: uuid(), jobType: 'biometric_kyc'),
idType: 'NATIONAL_ID',
idNumber: '12345678',
onSuccess: (VerifyVerdict v) => print(v.status),
onError: (e) => print(e),
);
Or call the facade directly without the bundled UI:
final verdict = await ApeniaVerify.instance.runDocumentVerification(
params: PartnerParams(userId: user.id, jobId: uuid()),
captureBack: true,
);
Architecture #
Dart app ─▶ ApeniaVerify (facade)
├─ native capture ──MethodChannel──▶ Android: LivenessActivity / DocumentCaptureActivity (ML Kit + CameraX)
│ iOS: LivenessViewController / DocumentCaptureViewController (Vision + AVFoundation)
└─ ApeniaClient ──HTTPS──▶ Apenia Verify API (create session → pre-signed PUT → poll/IPN)
└─ face-match + PAD scoring + ID-authority lookup
Capture returns local file paths over the channel (small payload); the
Dart ApeniaClient zips the frames + an info.json manifest and uploads.
Server contract (SDK capture flow) #
Full backend spec to hand to the API team:
doc/onboarding-sessions-api.md.
This is the SDK sibling of Apenia's hosted onboarding-links product. The
hosted flow (POST /v1/onboarding/links → hosted URL) runs capture on
Apenia's page; the SDK flow runs capture on-device and submits against an
onboarding session. Your backend creates the session with the secret
sk_…; the device then uses the publishable X-App-Key together with the
short-lived Bearer client token that create-session returned. The secret
never reaches the handset.
Verified against https://verify.apenia.com/docs — these are the documented endpoints, not assumptions. Device reads require
X-App-Keyand the sessionclient_token; the pre-signed upload URL needs no auth header.
1. POST /v1/onboarding/sessions (backend-proxied; mints the session) #
// request
{ "flow": "biometric_kyc", "metadata": { "user_id": "…", "job_id": "…", "customer_ref": "…" } }
// response (201)
{ "session_id": "onb_…", "upload_url": "https://…/v1/onboarding/uploads/…",
"client_token": "cst_…", "expires_at": "…" }
2. PUT <upload_url> (Content-Type: application/zip) #
The attachments and the on-device analysis travel together in one archive, so the server never has to correlate them after the fact:
apenia-upload.zip
├── info.json ← manifest + client_analysis (see §4)
├── id_front.jpg ← cropped to the document frame, ≤2000px long edge
├── id_back.jpg
├── selfie.jpg ← the primary, well-positioned frame
├── face_chip.jpg ← tight face crop at q0.95, for face matching
├── liveness_0.jpg ← one per satisfied challenge
└── liveness_1.jpg
info.json:
{
"session_id": "onb_…",
"metadata": { "user_id": "…", "job_id": "…", "id_type": "NATIONAL_ID",
"full_name": "…", "id_number": "…" },
"images": [ { "type": "id_front", "file": "id_front.jpg" }, … ],
"client_analysis": { /* MRZ fields, liveness, device checks — §4 */ }
}
Every image is listed with its type (selfie · liveness · id_front ·
id_back), and the MRZ fields the device read are in client_analysis, so the
server can compare its own extraction against the client's on the same request.
Limits: 32 MB archive, 8 MB per file — the SDK enforces both before
sending.
3. GET /v1/onboarding/sessions/{session_id} (or the onboarding.completed IPN) #
{ "session_id": "onb_…",
"status": "pending | processing | completed | rejected | review | failed",
"code": 1000, // 1xxx approved · 2xxx rejected · 3xxx review · 4xxx failed
"decision": "Approved",
"message": "…",
"subject_id": "sub_…",
"result": {
"face_match": { "score": 0.95, "match": true },
"liveness": { "score": 0.98, "live": true },
"voice": { "score": 0.90 },
"identity_confidence": 84
} }
Branch on
code, not onstatusor the scores. This is not theoretical: a real rejection comes back as"status": "completed"with"code": "2000"and"decision": "Rejected". Readingstatuswould approve it.VerifyVerdict.isApprovedand.statusderive fromcodefirst, thendecision, thenstatus. Notecodearrives as a string in practice.
Prefer the IPN to your backend as the source of truth; the SDK polls this
endpoint only to give the caller a synchronous VerifyVerdict. IPNs are signed
with X-Apenia-Signature: HMAC-SHA256(sk, raw_body) — verify it server-side.
Flows #
biometric_kyc, doc_verification, authentication,
enhanced_document_verification.
4. On-device analysis (ClientAnalysis) #
Everything the SDK worked out locally is packaged and sent for validation. It
always rides in the upload manifest under client_analysis; set
ApeniaConfig.analysisEndpoint to additionally POST it to your ML service:
{
"sdk": { "name": "apenia_verify", "version": "0.0.9", "platform": "android" },
"captured_at": "2026-07-18T09:14:22.000Z",
"document": {
"type": "NATIONAL_ID",
"capture_mode": "auto", // auto-detected vs manual shutter
"aspect_ratio": 1.586,
"sides_captured": 2,
"extracted": { // read from the MRZ on-device
"full_name": "…", "serial_number": "…", "id_number": "…",
"date_of_birth": "…", "sex": "…", "nationality": "…", "expiry_date": "…"
},
"capture": { // how the capture went, not just what it produced
"retake_count": 2, // captures the user rejected
"elapsed_ms": 8400, // time spent on the capture screen
"manual_shutter": true, // auto-detect never fired; user forced it
"sharpness": 11.3 // measured focus at capture
}
},
"liveness": {
"mode": "active",
"challenges_passed": ["smile", "blink"],
"frame_count": 2,
"face_chip": true, // a face crop rides in the archive
"frames": [ // geometry per saved frame
{ "tag": "selfie", "yaw": 1.4, "pitch": -2.1, "roll": 0.3,
"face_bounds": { "x": 0.31, "y": 0.22, "w": 0.38, "h": 0.30 },
"left_eye_open": 0.95, "right_eye_open": 0.93, "smiling": 0.05 },
{ "tag": "liveness_0", "challenge": "blink",
"left_eye_open": 0.07, "right_eye_open": 0.09, "smiling": 0.04 }
]
},
"device_checks": { "passed": 7, "total": 7, "confidence": 97,
"outcome": "clean", "checks": [ /* … */ ] },
// Present only when expectedName / expectedIdNumber are supplied.
"account_match": {
"name": { "expected": "Moses Gathecha", "document": "MOSES GATHECHA WAKANYI",
"verdict": "subset", "score": 1.0 }, // exact|subset|partial|disjoint
"id_number": { "expected": "26383680", "document": "26383680", "matches": true }
}
}
Reading frames. Each entry is evidence the prompted motion happened:
eye-openness collapsing on a blink frame, smiling rising on a smile
frame, yaw exceeding threshold on turnHead. Bounds are fractions of the
frame (top-left origin) on both platforms, and angles are degrees. This lets
the server verify the challenge rather than trust challenges_passed.
Reading account_match. This is the fake-account signal — the identity the
customer registered with versus the identity the document asserts. Names
are compared as token sets, so ordering, case, accents and a missing middle
name do not count as a mismatch (subset is the normal case). Treat
disjoint as grounds for review, never automatic rejection: marriage,
transliteration and registration typos all produce honest mismatches. Note it
proves only that the account matches the document — someone presenting a
stolen genuine ID matches perfectly, which is what face match exists for.
This is a signal, not a verdict — the device never decides whether someone is genuine. It lets the server cross-check its own extraction and scoring against the client's: a mismatch between server OCR and the on-device MRZ, a document captured manually rather than auto-detected, or a liveness sequence with fewer challenges than expected are all things worth flagging. The POST is best-effort and never blocks verification.
Security & compliance notes #
- No secret key on device.
sessionProviderreturns the session your backend created with the secretsk_…; the app only ever holds the short-livedclient_token. - Idempotent
job_id. A retried upload with the samejob_idmust not create a duplicate KYC record server-side. - Consent + audit. Capture explicit user consent before launching a
product screen and log it (Kenya DPA / GDPR). The
rawmap onVerifyVerdictis retained for your audit trail. - Data minimisation. Frames live in the OS cache/temp dir only until upload; delete them after a successful submit in production.
Status #
Early release (0.0.2). The Dart layer analyzes clean and is unit-tested, including the end-to-end upload contract. The capture flows have been exercised on real hardware: document capture, MRZ extraction and the liveness challenges on Android; the framed capture and liveness on iOS.
Known gaps before production:
- Liveness is active only — see the note above; passive PAD is scored server-side by Apenia Verify.
- iOS MRZ extraction is implemented and compiles but has had less on-device mileage than the Android path.
- Retry limits and capture timeouts are minimal; tune for your own UX.