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
Once published to Maven Central:
// app/build.gradle.kts
dependencies {
implementation("ai.thirdfactor:kyc-android:1.2.0")
}Or consume it as a Gradle module (source). 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"))
}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. |
ACCESS_FINE_LOCATION + ACCESS_COARSE_LOCATION | Precise location, for workflows that enable Ask for precise location (optionally Require precise location to continue). | Yes — only when the flow asks for it, never at launch. |
The library also declares <uses-feature android:name="android.hardware.nfc" android:required="false" /> (and the same for camera and location / location.gps) 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.
Precise location
When a workflow asks for precise location, the flow calls navigator.geolocation and the SDK answers the WebView's prompt: it grants the flow's origin (never any other) once the app holds the OS location permission, requesting it at runtime right then if needed. Workflows without precise location never show a location dialog.
- Approximate is accepted. From Android 12 the dialog offers precise or approximate; either grant is passed to the flow with its accuracy.
- Denied / "Don't ask again". The flow shows its own screen — Continue stays blocked when the workflow requires location — with Try again and guidance to allow location for your app in the phone's settings. Answers are never cached, so re-enabling location and tapping Try again works without restarting verification. When location is optional, a denial falls back to the coarse IP-based estimate.
- Don't want the permission? If none of your workflows ask for precise location, strip it with
tools:node="remove"on both<uses-permission>entries in your app manifest. The flow's location request is then refused (with a Logcat warning), and a workflow that requires it cannot be completed.
Geolocation needs a secure origin: it works on the https flow (and localhost), not on a plain-http dev URL such as http://10.0.2.2:3000.
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 (✕, task removed, or system back when the flow has no earlier step to go back to) before a terminal event. While the flow has an earlier step, system back steps back inside the flow instead. |
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
The flow's location step always says location is blocked, and no dialog appears. Either the applicant previously chose "Don't ask again" for location (they must allow it in the phone's Settings → Apps → your app → Permissions, then tap Try again), or your app manifest strips ACCESS_FINE_LOCATION / ACCESS_COARSE_LOCATION with tools:node="remove" — Logcat then shows a ThirdFactorKYC warning. On a plain-http dev URL the WebView refuses geolocation outright; test against the https flow.
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, answers the flow's navigator.geolocation prompt after ensuring runtime location (only when the flow asks; one permission dialog at a time), 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.