Skip to main content

apenia_verify 0.0.12 copy "apenia_verify: ^0.0.12" to clipboard
apenia_verify: ^0.0.12 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 page 1.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.documentCaptureMode selects manual (shutter, default) or auto (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 ApeniaVerifyPlatform interface — the Dart API and upload flow don't change.

Install #

flutter pub add apenia_verify

or in pubspec.yaml:

dependencies:
  apenia_verify: ^0.0.12

Android #

  • minSdk ≥ 24. The CAMERA permission 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 face and ocr models 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-Key and the session client_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 on status or the scores. This is not theoretical: a real rejection comes back as "status": "completed" with "code": "2000" and "decision": "Rejected". Reading status would approve it. VerifyVerdict.isApproved and .status derive from code first, then decision, then status. Note code arrives 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. sessionProvider returns the session your backend created with the secret sk_…; the app only ever holds the short-lived client_token.
  • Idempotent job_id. A retried upload with the same job_id must 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 raw map on VerifyVerdict is 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.
0
likes
0
points
267
downloads

Publisher

unverified uploader

Weekly Downloads

Apenia Verify — KYC capture SDK. Native active-liveness selfie + document scan; matching runs server-side in Apenia Verify.

Homepage

License

unknown (license)

Dependencies

archive, flutter, http, plugin_platform_interface

More

Packages that depend on apenia_verify

Packages that implement apenia_verify