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
| Package | com.thirdfactor.kyc |
| minSdk / compileSdk | 24 / 34 |
| Language / toolchain | Kotlin 1.9.22, coroutines, JVM target 17, AndroidX |
| Compose (optional) | Compose Compiler 1.5.8 (only for the rememberThirdFactorLauncher() helper) |
| NFC | JMRTD + 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:
| Permission | Why | Runtime prompt? |
|---|---|---|
INTERNET | Load the hosted flow. | No |
CAMERA | Document + selfie / liveness capture. | Yes — requested the first time the flow loads. |
NFC | ePassport / 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:
| Field | Type | Default | Notes |
|---|---|---|---|
title | String | "Identity verification" | Accessibility label for the verification screen. |
expectedOrigin | String? | origin of url | WebView is locked to this origin; only override behind a proxy. |
autoClose | Boolean | true | false 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
)status | When |
|---|---|
APPROVED | completed with server status approved. |
DECLINED | completed with declined. |
IN_REVIEW | completed with in_review / review / manual_review / pending, or any unknown terminal status (conservative). |
CANCELLED | completed with abandoned, or the user dismissed (back / ✕ / task removed) before a terminal event. |
ERROR | An 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
| Member | Signature | Notes |
|---|---|---|
ThirdFactor.Contract | class 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): VerificationResult | suspend 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)
| Type | Field | Type | Default | Notes |
|---|---|---|---|---|
VerifyRequest | url | String | — (required) | Hosted session URL from POST /v3/session/. |
VerifyRequest | options | VerifyOptions | VerifyOptions() | |
VerifyRequest | requestId | String | random UUID | Routes lifecycle events back to the right onEvent; generated for you. |
VerifyOptions | title | String | "Identity verification" | Accessibility label for the verification screen. |
VerifyOptions | expectedOrigin | String? | origin of url | WebView is locked to this origin; only override behind a proxy. |
VerifyOptions | autoClose | Boolean | true | false keeps the flow's own result screen until the user taps Done. |
Compose
| Member | Signature | Notes |
|---|---|---|
rememberThirdFactorLauncher(...) | @Composable fun rememberThirdFactorLauncher(onEvent: ((VerificationEvent) -> Unit)? = null, onResult: (VerificationResult) -> Unit): ThirdFactorLauncher | Wraps rememberLauncherForActivityResult around ThirdFactor.Contract. |
ThirdFactorLauncher.launch(...) | fun launch(url: String, options: VerifyOptions = VerifyOptions()) | Method on the object rememberThirdFactorLauncher returns. |
Result types
| Type | Shape | Notes |
|---|---|---|
VerificationResult | data class (status: VerificationStatus, sessionId: String? = null, rawStatus: String? = null, error: ThirdFactorError? = null) | Parcelable. |
VerificationStatus | enum class { APPROVED, DECLINED, IN_REVIEW, CANCELLED, ERROR } | |
ThirdFactorError | data class (code: String, message: String) | Parcelable. Branch on code. |
VerificationEvent | sealed 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)
| Member | Signature | Notes |
|---|---|---|
isAvailable(context) | fun isAvailable(context: Context): Boolean | Checks 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): NfcReadResult | overallTimeoutMs = 0 waits until cancelled. |
readTag(...) | fun readTag(isoDep: IsoDep, accessKey: NfcAccessKey, dataGroups: List<NfcDataGroup> = DEFAULT_DATA_GROUPS, transceiveTimeoutMs: Int = 15_000): NfcReadResult | Low-level — you own tag discovery (e.g. a custom NfcAdapter.ReaderCallback). |
NfcAccessKey | sealed interface { Mrz(documentNumber, dateOfBirth, dateOfExpiry), Can(canNumber) } | Mrz → BAC (+ PACE-with-MRZ when supported); Can → PACE-only eIDs. |
NfcDataGroup | enum { 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"). |
NfcErrorCode | object { USER_CANCELLED, TAG_LOST, WRONG_KEY, UNSUPPORTED, NFC_DISABLED, TIMEOUT } | String constants, not an enum. |
NfcReadResult | sealed 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.