Verification

Android SDK

com.thirdfactor.kyc — open the hosted flow in a WebView via the Activity Result contract, coroutines, or Compose, with built-in ePassport NFC.

com.thirdfactor.kyc opens the hosted verification flow in a full-screen WebView, bridges its lifecycle events, and resolves one normalized VerificationResult. Native NFC ePassport / eID chip reading is built in.

Warning

The client result is advisory. Grant access or move money only after your backend confirms the decision from the signed webhook (identity.kyc.session.approved / .declined / .review / .expired) or GET /v3/session/<id>/decision/.

Requirements

Packagecom.thirdfactor.kyc
minSdk / compileSdk24 / 34
Language / toolchainKotlin 1.9.22, coroutines, JVM target 17, AndroidX
Compose (optional)Compose Compiler 1.5.8 (only for the rememberThirdFactorLauncher() helper)
NFCJMRTD + SCUBA + BouncyCastle (bundled); a device with an NFC reader for chip steps

Installation

Delivered as a Gradle module (source), not yet a published artifact. Include it from settings.gradle.kts:

// settings.gradle.kts (host app)
include(":thirdfactor-kyc")
project(":thirdfactor-kyc").projectDir =
    file("path/to/thirdfactor-obsidian/sdks/android")
// app/build.gradle.kts
dependencies {
    implementation(project(":thirdfactor-kyc"))
}

Note

Maven coordinate (placeholder, once published): implementation("com.thirdfactor:kyc-android:<version>").

The module pulls androidx.core, androidx.activity, kotlinx-coroutines, the Compose runtime, androidx.webkit, and the NFC stack (org.jmrtd:jmrtd:0.7.42, net.sf.scuba:scuba-sc-android:0.0.23, org.bouncycastle:bcprov-jdk15to18:1.77). No manifest entries for the SDK's Activity / FileProvider are needed in your app — they ship in the library manifest.

Permissions

The library manifest declares what it needs; they merge into your app:

PermissionWhyRuntime prompt?
INTERNETLoad the hosted flow.No
CAMERADocument + selfie / liveness capture.Yes — requested the first time the flow loads.
NFCePassport / eID chip for the NFC_VERIFICATION step and the standalone ThirdFactorNfc API.No — the install-time permission is enough.

The library also declares <uses-feature android:name="android.hardware.nfc" android:required="false" /> (and the same for camera) so devices without the hardware can still install and run the rest of the flow — NFC then falls back to server-side review, and the SDK does not advertise the nfc capability.

Note

RECORD_AUDIO is not declared (the native flow captures no audio). If your workflow needs a microphone, add it to your app manifest and grant it at runtime; the SDK will then also grant RESOURCE_AUDIO_CAPTURE.

Quick start

import com.thirdfactor.kyc.*

class CheckoutActivity : ComponentActivity() {
    private val verify = registerForActivityResult(ThirdFactor.Contract()) { result ->
        if (result.status == VerificationStatus.APPROVED) onApproved()  // confirm server-side!
    }

    private fun startKyc(sessionUrl: String) = verify.launch(VerifyRequest(sessionUrl))
}

Integration methods

All three launchers resolve the same VerificationResult.

private val verify = registerForActivityResult(ThirdFactor.Contract()) { result ->
    when (result.status) {
        VerificationStatus.APPROVED  -> onApproved()          // confirm server-side!
        VerificationStatus.DECLINED  -> onDeclined()
        VerificationStatus.IN_REVIEW -> onPending()
        VerificationStatus.CANCELLED -> { /* user backed out */ }
        VerificationStatus.ERROR     -> onError(result.error)
    }
}

// launch with options / events:
verify.launch(VerifyRequest(sessionUrl, VerifyOptions(title = "Verify", autoClose = true)))

To observe lifecycle events, pass a callback to the contract: registerForActivityResult(ThirdFactor.Contract(onEvent = { e -> log(e) })) { … }.

Configuration

VerifyRequest(url, options, requestId) wraps the hosted url and options (requestId is generated for you). VerifyOptions is Parcelable:

FieldTypeDefaultNotes
titleString"Identity verification"Accessibility label for the verification screen.
expectedOriginString?origin of urlWebView is locked to this origin; only override behind a proxy.
autoCloseBooleantruefalse keeps the flow's own result screen until the user taps Done.

Note

onEvent is not a VerifyOptions field (a lambda cannot be parceled) — pass it separately to ThirdFactor.Contract(onEvent = …), rememberThirdFactorLauncher(onEvent = …), or ThirdFactor.verify(…, onEvent = …).

Result handling

data class VerificationResult(
    val status: VerificationStatus,      // APPROVED | DECLINED | IN_REVIEW | CANCELLED | ERROR
    val sessionId: String? = null,       // echoed by the flow when available
    val rawStatus: String? = null,       // raw server status, e.g. "abandoned"
    val error: ThirdFactorError? = null, // set only when status == ERROR
)
statusWhen
APPROVEDcompleted with server status approved.
DECLINEDcompleted with declined.
IN_REVIEWcompleted with in_review / review / manual_review / pending, or any unknown terminal status (conservative).
CANCELLEDcompleted with abandoned, or the user dismissed (back / ✕ / task removed) before a terminal event.
ERRORAn error bridge event or a load failure. Carries error = { code, message }.

Branch on error.code, never on error.message. Known codes: invalid_session, expired, missing_url, invalid_url, load_error.

private val verify = registerForActivityResult(
    ThirdFactor.Contract(onEvent = { e -> Log.d("kyc", e.toString()) })
) { result ->
    when (result.status) {
        VerificationStatus.APPROVED  -> proceed()             // confirm server-side first
        VerificationStatus.DECLINED  -> showFailure()
        VerificationStatus.IN_REVIEW -> showPending()
        VerificationStatus.CANCELLED -> { /* let them retry */ }
        VerificationStatus.ERROR     ->
            if (result.error?.code == "expired") reMintSession() else showError(result.error)
    }
}

NFC verification

The hosted flow runs in a WebView, which cannot reach the NFC radio. When the flow reaches an NFC_VERIFICATION step it hands the chip read to this SDK over the bridge's native-capability channel:

Handshake

Before the flow loads, the SDK injects window.__thirdfactorHost = { platform:"android", sdkVersion, capabilities:["nfc"] } — but only when the device actually has NFC turned on.

Command

The flow sends { type:"command", command:"nfc.scan", requestId, params } over the ThirdFactorAndroid bridge. params carry the MRZ the flow OCR'd (documentNumber + dateOfBirth + dateOfExpiry) or a canNumber.

Read

The SDK enables NFC reader mode and reads the chip with JMRTD (BAC/PACE + secure messaging), extracting the requested data groups (default DG1, DG2, SOD).

Result

The SDK calls window.ThirdFactorNative.resolveCommand(requestId, { ok:true, data:{ sod_b64, dg1_b64, dg2_b64, … } }), returning the raw chip bytes, base64. The flow uploads them to POST /api/verify/<token>/nfc/, which does passive authentication server-side.

This is fully automatic — if you launch the flow with ThirdFactor.verify / the contract on an NFC device, the chip step just works.

Warning

This SDK reads and forwards raw bytes only — it does not parse the SOD, hash the DGs, or verify the CSCA signer chain. All BAC/PACE/secure-messaging crypto is JMRTD's; none is hand-rolled. Error codes returned to the flow: user_cancelled, tag_lost, wrong_key (MRZ/CAN mismatch), unsupported, nfc_disabled, timeout.

Standalone NFC API

Read a chip directly, independent of the hosted flow (custom UI, offline enrolment, re-verification):

import com.thirdfactor.kyc.*

lifecycleScope.launch {
    val result = ThirdFactorNfc.read(
        activity = this@MyActivity,
        accessKey = NfcAccessKey.Mrz(
            documentNumber = "L898902C3",
            dateOfBirth    = "740812",   // YYMMDD
            dateOfExpiry   = "301001",   // YYMMDD
        ),
        dataGroups = listOf(NfcDataGroup.DG1, NfcDataGroup.DG2, NfcDataGroup.SOD),
        overallTimeoutMs = 60_000,       // give up if no chip is presented in 60 s
    )
    when (result) {
        is NfcReadResult.Success -> {
            val sod = result.dataGroups[NfcDataGroup.SOD]   // raw ByteArray
            // base64 + POST to your backend, which runs passive authentication.
        }
        is NfcReadResult.Failure -> showError(result.code)   // NfcErrorCode.*
    }
}

Public surface:

object ThirdFactorNfc {
    fun isAvailable(context: Context): Boolean
    suspend fun read(
        activity: Activity,
        accessKey: NfcAccessKey,                       // .Mrz(doc, dob, expiry) | .Can(can)
        dataGroups: List<NfcDataGroup> = DEFAULT_DATA_GROUPS,   // DG1, DG2, SOD
        transceiveTimeoutMs: Int = 15_000,             // per-APDU timeout
        overallTimeoutMs: Long = 0,                    // 0 = wait until cancelled
    ): NfcReadResult
    fun readTag(                                        // low-level: you own tag discovery
        isoDep: IsoDep,
        accessKey: NfcAccessKey,
        dataGroups: List<NfcDataGroup> = DEFAULT_DATA_GROUPS,
        transceiveTimeoutMs: Int = 15_000,
    ): NfcReadResult
}

NfcReadResult.Success carries dataGroups: Map<NfcDataGroup, ByteArray> plus activeAuth / chipAuth; Failure carries a stable code (NfcErrorCode.*) and message.

  • NfcAccessKey.Mrz(...) covers passports (BAC, with PACE when available).
  • NfcAccessKey.Can(...) covers PACE-only eIDs (6-digit CAN).

Note

The protocol's mrzKey param is not supported on Android — the flow sends the three MRZ fields it OCR'd, which is the path this SDK implements. nfc.scan reports aa and chipAuth as { performed:false, success:false } in this version; the backend performs passive authentication (and can validate AA/CA from DG15/DG14 + the SOD). On-device AA/CA is a future addition.

Packaging notes

The BouncyCastle jars ship signature / OSGi metadata that collide on packaging; the module's packaging { resources { excludes … } } block drops them, and R8 keep rules for the JMRTD/SCUBA/BC classes ship in consumer-rules.pro.

For the full NFC architecture and passive-auth decision mapping, see the NFC deep-dive.

Complete API reference

Every public symbol in com.thirdfactor.kyc, verified against sdks/android/src/main/java/com/thirdfactor/kyc/:

ThirdFactor

MemberSignatureNotes
ThirdFactor.Contractclass Contract(onEvent: ((VerificationEvent) -> Unit)? = null) : ActivityResultContract<VerifyRequest, VerificationResult>()Register with registerForActivityResult. parseResult returns CANCELLED if the Activity was torn down without a terminal event (process death / task removed).
ThirdFactor.verify(...)suspend fun verify(activity: ComponentActivity, url: String, options: VerifyOptions = VerifyOptions(), onEvent: ((VerificationEvent) -> Unit)? = null): VerificationResultsuspend convenience over the contract; safe to call mid-coroutine. Never throws for a verification outcome. Cancelling the calling coroutine unregisters the launcher (does not force-close the on-screen flow).

VerifyRequest / VerifyOptions (both @Parcelize)

TypeFieldTypeDefaultNotes
VerifyRequesturlString— (required)Hosted session URL from POST /v3/session/.
VerifyRequestoptionsVerifyOptionsVerifyOptions()
VerifyRequestrequestIdStringrandom UUIDRoutes lifecycle events back to the right onEvent; generated for you.
VerifyOptionstitleString"Identity verification"Accessibility label for the verification screen.
VerifyOptionsexpectedOriginString?origin of urlWebView is locked to this origin; only override behind a proxy.
VerifyOptionsautoCloseBooleantruefalse keeps the flow's own result screen until the user taps Done.

Compose

MemberSignatureNotes
rememberThirdFactorLauncher(...)@Composable fun rememberThirdFactorLauncher(onEvent: ((VerificationEvent) -> Unit)? = null, onResult: (VerificationResult) -> Unit): ThirdFactorLauncherWraps rememberLauncherForActivityResult around ThirdFactor.Contract.
ThirdFactorLauncher.launch(...)fun launch(url: String, options: VerifyOptions = VerifyOptions())Method on the object rememberThirdFactorLauncher returns.

Result types

TypeShapeNotes
VerificationResultdata class (status: VerificationStatus, sessionId: String? = null, rawStatus: String? = null, error: ThirdFactorError? = null)Parcelable.
VerificationStatusenum class { APPROVED, DECLINED, IN_REVIEW, CANCELLED, ERROR }
ThirdFactorErrordata class (code: String, message: String)Parcelable. Branch on code.
VerificationEventsealed class { Ready, Completed(status: VerificationStatus, sessionId: String?), Error(code: String, message: String), Close }Not Parcelable (it's a callback stream, not an Intent extra) — this is why onEvent is passed separately from VerifyOptions.

ThirdFactorNfc (standalone NFC)

MemberSignatureNotes
isAvailable(context)fun isAvailable(context: Context): BooleanChecks the device has NFC hardware and it's currently enabled.
read(...)suspend fun read(activity: Activity, accessKey: NfcAccessKey, dataGroups: List<NfcDataGroup> = DEFAULT_DATA_GROUPS, transceiveTimeoutMs: Int = 15_000, overallTimeoutMs: Long = 0): NfcReadResultoverallTimeoutMs = 0 waits until cancelled.
readTag(...)fun readTag(isoDep: IsoDep, accessKey: NfcAccessKey, dataGroups: List<NfcDataGroup> = DEFAULT_DATA_GROUPS, transceiveTimeoutMs: Int = 15_000): NfcReadResultLow-level — you own tag discovery (e.g. a custom NfcAdapter.ReaderCallback).
NfcAccessKeysealed interface { Mrz(documentNumber, dateOfBirth, dateOfExpiry), Can(canNumber) }Mrz → BAC (+ PACE-with-MRZ when supported); Can → PACE-only eIDs.
NfcDataGroupenum { COM, DG1..DG16, SOD }DEFAULT_DATA_GROUPS = [DG1, DG2, SOD]. Each carries the ICAO fid and the bridge wire key (e.g. SOD → "sod_b64").
NfcErrorCodeobject { USER_CANCELLED, TAG_LOST, WRONG_KEY, UNSUPPORTED, NFC_DISABLED, TIMEOUT }String constants, not an enum.
NfcReadResultsealed class { Success(dataGroups: Map<NfcDataGroup, ByteArray>, activeAuth: NfcAuthResult, chipAuth: NfcAuthResult), Failure(code: String, message: String) }Both activeAuth/chipAuth are { performed = false, success = false } in this version — the backend validates AA/CA.

Full end-to-end example

package com.acme.app

import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.lifecycle.lifecycleScope
import com.thirdfactor.kyc.*
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject

class VerifyActivity : ComponentActivity() {

    // Idiomatic: register unconditionally as a property, before onCreate/onStart run.
    private val verifyLauncher = registerForActivityResult(
        ThirdFactor.Contract(onEvent = { e -> Log.d("kyc", e.toString()) }),
    ) { result -> handleResult(result) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Button(onClick = { startVerification() }) { Text("Verify identity") }
        }
    }

    private fun startVerification() {
        lifecycleScope.launch {
            // Call YOUR backend, which calls POST /v3/session/ with your secret key.
            val url = fetchSessionUrl() ?: return@launch
            verifyLauncher.launch(
                VerifyRequest(url, VerifyOptions(title = "Verify your identity", autoClose = true)),
            )
        }
    }

    private suspend fun fetchSessionUrl(): String? {
        val client = OkHttpClient()
        val request = Request.Builder()
            .url("https://api.acme.com/kyc/session")
            .post(okhttp3.RequestBody.create(null, ByteArray(0)))
            .build()
        return runCatching {
            client.newCall(request).execute().use { resp ->
                JSONObject(resp.body?.string() ?: "{}").optString("url")
            }
        }.getOrNull()
    }

    private fun handleResult(result: VerificationResult) {
        when (result.status) {
            VerificationStatus.APPROVED  -> Log.i("kyc", "approved — confirm server-side")
            VerificationStatus.DECLINED  -> Log.i("kyc", "declined")
            VerificationStatus.IN_REVIEW -> Log.i("kyc", "in review")
            VerificationStatus.CANCELLED -> Log.i("kyc", "cancelled")
            VerificationStatus.ERROR     -> Log.e("kyc", "error: ${result.error?.code}")
        }
    }
}

Troubleshooting

Warning

IllegalStateException: LifecycleOwner ... is attempting to register while current state is RESUMED. registerForActivityResult(ThirdFactor.Contract()) { ... } must be called unconditionally as an Activity/Fragment property, before onCreate/onStart completes — not lazily inside a button click or a conditional branch. Register it once, then call .launch(...) from wherever the user taps "Verify."

Warning

Camera permission dialog never appears, or the flow reports a camera error immediately. The SDK requests CAMERA at runtime automatically the first time the flow loads — but only if the app itself hasn't already permanently denied it (user tapped "Don't ask again" on a prior denial). If your app also requests CAMERA elsewhere, make sure you're not swallowing or short-circuiting that request; check ActivityCompat.shouldShowRequestPermissionRationale if verification silently fails after a first denial.

Warning

Build fails with a BouncyCastle / OSGi duplicate-resource error. This happens if you add the NFC stack's dependencies (org.jmrtd, net.sf.scuba, org.bouncycastle) again in your own app module — they're already bundled by sdks/android. Don't redeclare them; if you must, mirror the module's packaging { resources { excludes ... } } block exactly.

Warning

NFC reads fail only in a minified (R8/ProGuard) release build, not in debug. The module ships consumer-rules.pro with keep rules for JMRTD/SCUBA/BouncyCastle, but if your app's own proguard-rules.pro applies additional aggressive rules (e.g. -dontwarn blanket excludes or a custom shrinker config) they can still strip reflection-based classes those libraries rely on. Verify your merged R8 config includes the SDK's consumer rules (Android Studio → "Analyze APK" → check for missing JMRTD classes if reads fail only in release).

Warning

Package name / applicationId mismatch breaks nothing for verification itself — unlike iOS, Android NFC and camera permissions are not bound to a specific applicationId. If verification behaves differently between your debug and release builds, look at ProGuard/R8 and permission state first, not the package name.

FAQ

How it works

verify / the contract start ThirdFactorActivity, which appends embed=1&sdk=android to your url, loads it in a WebView (javaScriptEnabled, domStorageEnabled, mediaPlaybackRequiresUserGesture=false), injects the bridge under the name ThirdFactorAndroid with @JavascriptInterface fun postMessage(String), grants getUserMedia after ensuring runtime CAMERA, keeps navigation on the flow origin, and returns exactly one result (completed-then-close and back-button races are guarded). See the bridge protocol for the full contract.