Wallet Issuance ยท SDKWallet SDK โ Integration Guide
Version 1.0.0-SNAPSHOT ยท Android (API 26+) ยท iOS 14+ (XCFramework) ยท Last updated: 2026-03-23
AndroidiOSKotlin MultiplatformOpenID4VCIISO 18013-5
The Wallet SDK is a Kotlin Multiplatform library for building mobile identity wallet applications. It provides a complete pipeline for:
- Provisioning โ issuing credentials from an OpenID4VCI-compliant issuer via a two-phase flow.
- Storage โ persisting credentials in a hardware-backed secure store using the library.
- Presentation โ sharing credentials in-person (ISO 18013-5 QR/BLE), online (OpenID4VP deep links), and in the browser (Android Digital Credentials API).
- Document Capture โ scanning and extracting structured data from physical identity documents.
- Liveness Detection โ biometric face capture with optional on-device passive liveness.
- MRTD Reading โ reading NFC chips embedded in passports and travel documents.
The SDK is built on Clean Architecture principles. Your application always interacts through stable engine interfaces.
text
Your App
โโ WalletSdk (singleton)
โโ ProvisioningEngine โ OpenID4VCI credential issuance
โโ StorageEngine โ credential vault (StateFlow, delete)
โโ PresentationEngine โ QR/BLE, deep link, DC API
โโ DocumentCaptureEngine โ OCR / barcode extraction
- Platform
- Android
- Requirement
- API 26 (Android 8.0) minimum; API 29+ for the Digital Credentials API browser flow
- Platform
- iOS
- Requirement
- iOS 14+ โ macOS build machine required (iOS targets are skipped on Windows)
- Platform
- Kotlin
- Requirement
- 2.2.0
- Platform
- Google Play Services
- Requirement
- Required for Android Credential Manager (DC API flow)
- Platform
- Chrome
- Requirement
- 128+ for web-based Digital Credentials support
| Platform | Requirement |
|---|
| Android | API 26 (Android 8.0) minimum; API 29+ for the Digital Credentials API browser flow |
| iOS | iOS 14+ โ macOS build machine required (iOS targets are skipped on Windows) |
| Kotlin | 2.2.0 |
| JVM | 11 |
| Google Play Services | Required for Android Credential Manager (DC API flow) |
| Chrome | 128+ for web-based Digital Credentials support |
You will need an API key issued by CredenceID to initialize the SDK. Contact your CredenceID account representative if you do not have one.
3.1 Android (Gradle)
Add the CredenceID Maven repository and the SDK dependency to your module's build.gradle.kts. Use the plain artifact notation โ do not append @aar or -android-release suffixes, as these bypass Gradle module metadata and strip transitive dependencies.
kotlin
// settings.gradle.kts or root build.gradle.kts
repositories {
// CredenceID SDK โ publicly readable, covers releases and snapshots
maven("https://nexus.credenceid.com/repository/credenceid-sdk-group/")
// Required only if you use the liveness module with Innovatrics provider
maven("https://maven.innovatrics.com/releases")
}
// app/build.gradle.kts
dependencies {
// Core Wallet SDK (includes document capture and MRTD reader transitively)
implementation("com.credenceid:wallet-sdk:1.0.0-SNAPSHOT")
// Optional โ liveness capture module
// Add this only if your app needs selfie or passive liveness capture.
implementation("com.credenceid:wallet-sdk-liveness:1.0.0-SNAPSHOT")
}
โน๏ธSnapshot builds
Replace 1.0.0-SNAPSHOT with a specific release tag (e.g. 0.0.3) once a stable release is available. Use 1.0.0-SNAPSHOT during development to track the latest build.
Common mistakes to avoid:
kotlin
// โ Wrong โ @aar strips ALL transitive dependencies
implementation("com.credenceid:wallet-sdk:1.0.0-SNAPSHOT@aar")
// โ Wrong โ -android-release is an internal variant; always use the root coordinate
implementation("com.credenceid:wallet-sdk-android-release:1.0.0-SNAPSHOT")
// โ
Correct
implementation("com.credenceid:wallet-sdk:1.0.0-SNAPSHOT")
3.2 iOS (XCFramework)
The SDK ships as a pre-built static WalletSDK.xcframework. Obtain the framework from your CredenceID repository or CI artifact.
- In Xcode, choose File โ Add Files to "<Project>".
- Select
WalletSDK.xcframework and add it to your target. - In Target โ General โ Frameworks, Libraries, and Embedded Content, set the framework to Do Not Embed (it is a static framework โ embedding it causes a duplicate symbols error at link time).
โ
SKIE interop
StateFlow and other Kotlin coroutine primitives are automatically bridged to Swift's native
AsyncSequence by the
SKIE interop layer bundled inside the framework. No manual bridging is required.
The SDK is a process-scoped singleton accessed via WalletSdk.getInstance(). The call is thread-safe and idempotent โ repeated calls always return the same instance.
kotlin
import com.credenceid.wallet.sdk.WalletSdk
// Obtain the singleton โ safe to call from any thread, any time.
val sdk = WalletSdk.getInstance(apiKey = "your-api-key")
// Engines are lazily initialized on first access.
val provisioningEngine = sdk.provisioningEngine
val storageEngine = sdk.storageEngine
val presentationEngine = sdk.presentationEngine
val documentCapture = sdk.documentCaptureEngine
When to initialize: Call getInstance() the first time before you access any engine โ for example, in your Application.onCreate() or the first ViewModel that needs the SDK. There is no separate init() call required.
API key: The key is validated internally. Passing an empty or invalid key will not throw at construction time but may result in network requests failing during provisioning.
05Credential Provisioning
Provisioning is a two-phase flow that follows the OpenID4VCI pre-authorized code specification:
- Phase
- Phase 1
- Method
- submitProvisioningRequest()
- What happens
- Submits the applicant's identity to the issuer backend. A PENDING credential is persisted locally.
- Phase
- Phase 2
- Method
- fetchReadyCredential()
- What happens
- The issuer processes the application and pushes a credential offer URI out-of-band. The SDK exchanges it for the signed mDoc and marks the credential VALID.
| Phase | Method | What happens |
|---|
| Phase 1 | submitProvisioningRequest() | Submits the applicant's identity to the issuer backend. A PENDING credential is persisted locally. |
| Phase 2 | fetchReadyCredential() | The issuer processes the application and pushes a credential offer URI out-of-band. The SDK exchanges it for the signed mDoc and marks the credential VALID. |
5.1 Phase 1 โ Submit Application
Build a ProvisioningParameters object with the issuer endpoint, credential type, and the applicant's identity data, then call submitProvisioningRequest().
kotlin
import com.credenceid.wallet.sdk.WalletSdk
import com.credenceid.wallet.sdk.domain.factory.provisioning.DigitalIdApplicationFactory
import com.credenceid.wallet.sdk.domain.model.provisioning.ProvisioningParameters
val sdk = WalletSdk.getInstance(apiKey = "your-api-key")
viewModelScope.launch {
val params = ProvisioningParameters(
issuerUrl = "https://issuer.example.com/reviewcenter",
credentialType = "org.iso.18013.5.1.mDL",
userAuthorizationToken = "pre-auth-token-from-your-backend",
digitalIdApplication = DigitalIdApplicationFactory.createSample(),
// Replace createSample() with a real DigitalIDApplication built from
// the applicant's captured document data and selfie.
)
sdk.provisioningEngine.submitProvisioningRequest(params)
.onSuccess {
// A PENDING DigitalCredential is now observable in storageEngine.storedCredentials.
// Inform the user that their application is under review.
showMessage("Application submitted โ waiting for issuer approval.")
}
.onFailure { error ->
showError("Submission failed: ${error.message}")
}
}
ProvisioningParameters fields:
- Field
- issuerUrl
- Type
- String
- Description
- Base URL of the issuer's proprietary REST API.
- Field
- credentialType
- Type
- String
- Description
- Credential type to request, e.g. "org.iso.18013.5.1.mDL".
- Field
- userAuthorizationToken
- Type
- String
- Description
- Pre-authorized code or auth token from your backend authorization layer.
- Field
- digitalIdApplication
- Type
- DigitalIDApplication
- Description
- Applicant identity data (name, DOB, document images, selfie, etc.) submitted in Phase 1.
| Field | Type | Description |
|---|
| issuerUrl | String | Base URL of the issuer's proprietary REST API. |
| credentialType | String | Credential type to request, e.g. "org.iso.18013.5.1.mDL". |
| userAuthorizationToken | String | Pre-authorized code or auth token from your backend authorization layer. |
| digitalIdApplication | DigitalIDApplication | Applicant identity data (name, DOB, document images, selfie, etc.) submitted in Phase 1. |
5.2 Phase 2 โ Fetch the Issued Credential
Once the issuer has approved and signed the credential, it delivers a openid-credential-offer:// URI to the device. The SDK supports two delivery methods:
- Deep link โ the issuer redirects the user's browser to the
openid-credential-offer:// URI, which Android or iOS routes to your app via its registered scheme. - QR code โ the issuer displays a QR code containing the offer URI. The user scans it in-app (or with the device camera), and your app extracts the raw string to pass to the SDK.
Pass the URI directly to fetchReadyCredential() regardless of how it was delivered.
kotlin
// --- Deep link delivery ---
// Register your scheme in AndroidManifest.xml:
// <intent-filter>
// <action android:name="android.intent.action.VIEW" />
// <category android:name="android.intent.category.DEFAULT" />
// <category android:name="android.intent.category.BROWSABLE" />
// <data android:scheme="openid-credential-offer" />
// </intent-filter>
//
// Then handle the incoming intent in your Activity:
viewModelScope.launch {
// Example URI (deep link or decoded from QR):
// openid-credential-offer://?credential_offer_uri=https://issuer.example.com/offers/abc123
val credentialOfferUri = intent.data?.toString() ?: return@launch
sdk.provisioningEngine.fetchReadyCredential(
credentialOfferUri = credentialOfferUri,
timeoutMillis = 30_000L, // Optional โ default is 30 seconds
)
.onSuccess { credential ->
// The credential is now VALID and observable in the storage engine.
showSuccess("Credential ready: ${credential.displayMetadata["displayName"]}")
}
.onFailure { error ->
when {
error.message?.contains("TIMEOUT") == true ->
showRetryDialog("The issuer did not respond in time. Try again?")
else ->
showError("Could not download credential: ${error.message}")
}
}
}
For the QR code path, decode the QR string using any standard library (e.g. ZXing / ML Kit Barcode Scanning) and pass the raw result string directly โ no further parsing is needed. The pre-authorized code is embedded inside the offer URI's JSON payload and extracted automatically by the underlying SDK library.
kotlin
// --- QR code delivery ---
// After scanning a QR code with your chosen library:
fun onQrCodeScanned(rawValue: String) {
if (rawValue.startsWith("openid-credential-offer://")) {
viewModelScope.launch {
sdk.provisioningEngine.fetchReadyCredential(rawValue)
.onSuccess { credential -> showSuccess(credential) }
.onFailure { error -> showError(error.message) }
}
}
}
5.3 Complete Provisioning Flow Example
The following example demonstrates both phases together with a ViewModel, including UI state management:
kotlin
class ProvisioningViewModel : ViewModel() {
private val sdk = WalletSdk.getInstance(apiKey = "your-api-key")
sealed interface UiState {
object Idle : UiState
object Submitting : UiState
object AwaitingIssuer : UiState
data class Ready(val credential: DigitalCredential) : UiState
data class Error(val message: String) : UiState
}
private val _uiState = MutableStateFlow<UiState>(UiState.Idle)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
// Called when the user taps "Apply"
fun submitApplication(params: ProvisioningParameters) {
_uiState.value = UiState.Submitting
viewModelScope.launch {
sdk.provisioningEngine.submitProvisioningRequest(params)
.onSuccess { _uiState.value = UiState.AwaitingIssuer }
.onFailure { _uiState.value = UiState.Error(it.message ?: "Unknown error") }
}
}
// Called when a deep link or QR code scan delivers the credential offer URI
fun onCredentialOfferReceived(credentialOfferUri: String) {
viewModelScope.launch {
sdk.provisioningEngine.fetchReadyCredential(credentialOfferUri)
.onSuccess { _uiState.value = UiState.Ready(it) }
.onFailure { _uiState.value = UiState.Error(it.message ?: "Download failed") }
}
}
}
06Credential Storage & Vault Management
The StorageEngine exposes the credential vault as a reactive StateFlow. Your UI layer simply collects the flow โ no polling or manual refresh required.
6.1 Observing the Credential Vault
kotlin
import kotlinx.coroutines.flow.collectLatest
// In a ViewModel:
viewModelScope.launch {
sdk.storageEngine.storedCredentials.collectLatest { credentials ->
// Emitted immediately with the current list, then again on every change.
// credentials: List<DigitalCredential>
_credentialList.value = credentials
}
}
DigitalCredential fields:
- Field
- id
- Type
- String
- Description
- Unique identifier assigned by the DocumentStore.
- Field
- type
- Type
- String
- Description
- Credential type, e.g. "org.iso.18013.5.1.mDL".
- Field
- provisioningStatus
- Type
- ProvisioningStatus
- Description
- PENDING while awaiting the issuer; VALID once issued; REVOKED if revoked.
- Field
- displayMetadata
- Type
- Map<String, String>
- Description
- Display-oriented metadata. Contains "displayName" when the issuer provides one.
| Field | Type | Description |
|---|
| id | String | Unique identifier assigned by the DocumentStore. |
| type | String | Credential type, e.g. "org.iso.18013.5.1.mDL". |
| provisioningStatus | ProvisioningStatus | PENDING while awaiting the issuer; VALID once issued; REVOKED if revoked. |
| displayMetadata | Map<String, String> | Display-oriented metadata. Contains "displayName" when the issuer provides one. |
Displaying credentials in a Compose list:
kotlin
@Composable
fun CredentialList(viewModel: WalletViewModel) {
val credentials by viewModel.credentials.collectAsStateWithLifecycle()
LazyColumn {
items(credentials) { credential ->
CredentialCard(
name = credential.displayMetadata["displayName"] ?: credential.type,
status = credential.provisioningStatus.name,
// Show a visual indicator for PENDING credentials
isPending = credential.provisioningStatus == ProvisioningStatus.PENDING,
)
}
}
}
6.2 Deleting a Credential
Deletion permanently erases the mDoc payload and destroys the associated hardware-backed cryptographic keys in the SecureArea. This operation is irreversible.
kotlin
fun deleteCredential(credentialId: String) {
viewModelScope.launch {
sdk.storageEngine.deleteCredential(credentialId)
.onSuccess {
// storedCredentials flow automatically emits the updated list.
showToast("Credential deleted.")
}
.onFailure { error ->
showError("Could not delete credential: ${error.message}")
}
}
}
โ
Best practice
Always prompt the user for explicit confirmation before calling deleteCredential(). The operation destroys the hardware-backed key โ recovery requires re-provisioning from the issuer.
07Credential Presentation
The SDK supports three presentation modes, all driven by the same PresentationEngine:
- Mode
- In-person
- Trigger
- User taps "Show credential"
- Protocol
- ISO 18013-5 (QR code + BLE)
- Mode
- Online
- Trigger
- Deep link from verifier website
- Protocol
- OpenID4VP / ISO 18013-7
- Mode
- Browser (DC API)
- Trigger
- Web page calls navigator.credentials.get()
- Protocol
- OpenID4VP, mdoc Annex C
| Mode | Trigger | Protocol |
|---|
| In-person | User taps "Show credential" | ISO 18013-5 (QR code + BLE) |
| Online | Deep link from verifier website | OpenID4VP / ISO 18013-7 |
| Browser (DC API) | Web page calls navigator.credentials.get() | OpenID4VP, mdoc Annex C |
7.1 How the Presentation Lifecycle Works
All presentation modes share the same state machine, expressed as a StateFlow<PresentationState>:
text
Idle
โโ> EngagementReady (QR code ready โ in-person only)
โโ> Connecting (verifier is connecting)
โโ> RequestReceived (verifier sent a request โ show consent UI)
โโ> ConsentGiven (user approved โ signing in progress)
โโ> Success (credential delivered)
โโ> Error (something went wrong)
Your UI observes this flow and updates accordingly at each transition.
7.2 In-Person Presentation (ISO 18013-5 QR/BLE)
The in-person flow starts by generating a Device Engagement QR code. The verifier scans it, establishes a secure BLE channel, and sends a credential request. The user consents and the SDK signs and returns the response.
kotlin
import com.credenceid.wallet.sdk.domain.model.presentation.PresentationState
viewModelScope.launch {
sdk.presentationEngine
.startInPersonPresentation()
.collectLatest { state ->
when (state) {
is PresentationState.Idle -> {
// Initial state โ no action needed.
}
is PresentationState.EngagementReady -> {
// Display the QR code for the verifier to scan.
// state.qrCodePayload is the raw string; encode it as a QR image.
showQrCode(payload = state.qrCodePayload)
}
is PresentationState.Connecting -> {
// Verifier scanned the QR code โ hide the QR and show a spinner.
showConnectingSpinner()
}
is PresentationState.RequestReceived -> {
// Verifier sent a credential request. Display the consent screen.
showConsentScreen(
verifier = state.verifierIdentity,
claims = state.requestedClaims,
)
// The user will call approveRequest() or rejectRequest() from the consent UI.
}
is PresentationState.Success -> {
showMessage("Credential shared successfully.")
navigateBack()
}
is PresentationState.Error -> {
val err = state.error
if (err.isRecoverable) {
showRetryDialog(err.code.name)
} else {
showError("Presentation failed: ${err.code.name}")
}
}
}
}
}
// Called when the user taps "Share" on the consent screen
fun onUserApproved(selectedClaims: List<String>, biometricToken: ByteArray) {
viewModelScope.launch {
sdk.presentationEngine
.approveRequest(claims = selectedClaims, biometricToken = biometricToken)
.onFailure { showError("Could not sign response: ${it.message}") }
}
}
// Called when the user taps "Decline"
fun onUserDeclined() {
viewModelScope.launch {
sdk.presentationEngine.rejectRequest()
}
}
7.3 Online Presentation (Deep Link / OpenID4VP)
Online presentation is triggered by a deep link from a verifier's website. Register your app as a deep link handler for the verifier's scheme and pass the URL to startOnlinePresentation().
kotlin
// In your Activity โ handle incoming deep links:
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
val url = intent.data?.toString() ?: return
viewModelScope.launch {
sdk.presentationEngine
.startOnlinePresentation(requestUrl = url)
.collectLatest { state ->
// Handle states identically to the in-person flow.
// The EngagementReady state is skipped (no QR needed for online flows).
handlePresentationState(state)
}
}
}
private fun handlePresentationState(state: PresentationState) {
when (state) {
is PresentationState.Connecting -> showLoadingIndicator()
is PresentationState.RequestReceived -> showConsentScreen(state.verifierIdentity, state.requestedClaims)
is PresentationState.Success -> showSuccess()
is PresentationState.Error -> showError(state.error.code.name)
else -> { /* Idle / EngagementReady not expected in online flow */ }
}
}
7.4 Digital Credentials API (DC API) โ Android Browser Flow
When a verifier's website calls navigator.credentials.get() from Chrome (v128+) or another supported browser, Android's Credential Manager matches the request against registered credentials in the SDK's DocumentStore and routes it to your app via the SDK's WalletPresentmentActivity (declared in the SDK's merged manifest โ no configuration needed in your app).
What the SDK handles automatically:
- Responsibility
- Credential registration
- Implementation
- Dual-path: GMS IdentityCredentialManager + Jetpack RegistryManager. Re-registered on every vault change.
- Responsibility
- Intent routing
- Implementation
- WalletPresentmentActivity is declared in the SDK manifest โ auto-merged into your app.
- Responsibility
- Request parsing
- Implementation
- Supports OpenID4VP (unsigned + signed) and mdoc Annex C (ISO 18013-7).
- Responsibility
- Response signing
- Implementation
- Handles DeviceResponse ECDSA signing and JWE/HPKE encryption.
| Responsibility | Implementation |
|---|
| Credential registration | Dual-path: GMS IdentityCredentialManager + Jetpack RegistryManager. Re-registered on every vault change. |
| Intent routing | WalletPresentmentActivity is declared in the SDK manifest โ auto-merged into your app. |
| Request parsing | Supports OpenID4VP (unsigned + signed) and mdoc Annex C (ISO 18013-7). |
| Response signing | Handles DeviceResponse ECDSA signing and JWE/HPKE encryption. |
What your app must do: Observe activeSession and display a consent UI when a session arrives.
kotlin
// Observe in your Application or a long-lived ViewModel:
viewModelScope.launch {
sdk.presentationEngine.activeSession.collect { session ->
if (session != null) {
// A verifier request is pending. Show your consent UI.
showConsentDialog(
claims = session.requestedClaims,
verifier = session.verifierIdentity,
onApprove = { lifecycleScope.launch { session.approve() } },
onReject = { lifecycleScope.launch { session.reject() } },
)
}
}
}
If you prefer the SDK's built-in Material3 bottom sheet, no code is needed at all โ it renders automatically inside the transparent WalletPresentmentActivity. Use the activeSession API only if you want a custom-themed consent UI.
7.5 Consent UI
The consent UI must surface the following information to the user.
Requested claims (requestedClaims: List<ClaimRequest>):
kotlin
data class ClaimRequest(
val namespace: String, // e.g. "org.iso.18013.5.1"
val claimName: String, // e.g. "family_name", "portrait", "age_over_18"
val intentToRetain: Boolean // true = verifier intends to store this data
)
Display each claimName in plain language. When intentToRetain is true, indicate clearly that the verifier will store that data element โ this disclosure is required by ISO 18013-5.
Verifier identity (verifierIdentity: VerifierInfo?):
kotlin
data class VerifierInfo(
val commonName: String,
val organization: String?,
val isCertificateValid: Boolean,
val trustLevel: TrustLevel // TRUSTED, UNKNOWN, WARNING
)
See Section 7.6 for how to handle each trust level.
Example consent composable:
kotlin
@Composable
fun ConsentScreen(
claims: List<ClaimRequest>,
verifier: VerifierInfo?,
onApprove: () -> Unit,
onReject: () -> Unit,
) {
Column(modifier = Modifier.padding(16.dp)) {
// Verifier identity
verifier?.let {
TrustLevelBadge(level = it.trustLevel)
Text("${it.commonName} is requesting access to:")
}
// Requested claims
claims.forEach { claim ->
Row {
Text("โข ${claim.claimName}")
if (claim.intentToRetain) {
Text(
text = " (will be stored)",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
// Action buttons
Row {
Button(onClick = onApprove) { Text("Share") }
OutlinedButton(onClick = onReject) { Text("Decline") }
}
}
}
7.6 Verifier Trust Levels
Always reflect trust levels in your consent UI. The table below shows recommended user-facing messaging:
- TrustLevel
- TRUSTED
- Meaning
- Certificate validated against a known trust root.
- UI guidance
- Show a green verified indicator.
- TrustLevel
- UNKNOWN
- Meaning
- No certificate โ verifier is unauthenticated.
- UI guidance
- Show a warning. Ask the user to verify they are on the expected website.
- TrustLevel
- WARNING
- Meaning
- Certificate present but expired, self-signed, or from an untrusted root.
- UI guidance
- Show a prominent warning. Consider blocking sharing by default.
| TrustLevel | Meaning | UI guidance |
|---|
| TRUSTED | Certificate validated against a known trust root. | Show a green verified indicator. |
| UNKNOWN | No certificate โ verifier is unauthenticated. | Show a warning. Ask the user to verify they are on the expected website. |
| WARNING | Certificate present but expired, self-signed, or from an untrusted root. | Show a prominent warning. Consider blocking sharing by default. |
kotlin
@Composable
fun TrustLevelBadge(level: TrustLevel) {
val (color, label) = when (level) {
TrustLevel.TRUSTED -> Color(0xFF2E7D32) to "Verified"
TrustLevel.UNKNOWN -> Color(0xFFF57F17) to "Unverified"
TrustLevel.WARNING -> Color(0xFFC62828) to "Warning โ Certificate Issue"
}
Text(text = label, color = color, fontWeight = FontWeight.Bold)
}
The Wallet Toolkit regroups the capture and reading features that together feed the provisioning pipeline with verified identity data.
8.1 Document Capture
The DocumentCaptureEngine captures identity document images and extracts structured data (MRZ for passports and ID cards, PDF417 barcode for driving licences).
The engine transparently selects the provider defined in backend.
8.1.1 Capture State Lifecycle
text
Idle โ CapturingFront โ [CapturingBack] โ Analyzing โ Success
โโ Error
- State
- Idle
- Description
- No session active (initial state).
- State
- CapturingFront
- Description
- Camera open for the front face or passport bio-page.
- State
- CapturingBack
- Description
- Front captured; camera open for the rear face (not used for passports).
- State
- Analyzing
- Description
- Images acquired; OCR / barcode extraction in progress.
- State
- Success(document)
- Description
- Capture and extraction complete.
- State
- Error(error)
- Description
- Session failed. See DocumentCaptureErrorCode.
| State | Description |
|---|
| Idle | No session active (initial state). |
| CapturingFront | Camera open for the front face or passport bio-page. |
| CapturingBack | Front captured; camera open for the rear face (not used for passports). |
| Analyzing | Images acquired; OCR / barcode extraction in progress. |
| Success(document) | Capture and extraction complete. |
| Error(error) | Session failed. See DocumentCaptureErrorCode. |
8.1.2 Capturing a National ID Card
The ID card flow captures front and back sides and extracts the MRZ from the back (TD1 format: 3 lines ร 30 characters).
kotlin
import com.credenceid.wallet.sdk.domain.model.documentcapture.*
val engine = sdk.documentCaptureEngine
// Step 1 โ collect state changes to drive the UI.
viewModelScope.launch {
engine.captureState.collectLatest { state ->
when (state) {
is DocumentCaptureState.Idle -> showIdle()
is DocumentCaptureState.CapturingFront -> showGuidance("Place the front of your ID in the frame")
is DocumentCaptureState.CapturingBack -> showGuidance("Now flip your ID over")
is DocumentCaptureState.Analyzing -> showSpinner("Reading document dataโฆ")
is DocumentCaptureState.Success -> {
val doc = state.document
// Full-resolution JPEG images โ send to your DocAuth / verification service.
uploadToVerificationService(
front = doc.frontImageBytes,
back = doc.backImageBytes,
)
// Parsed MRZ data (TD1) for pre-filling the provisioning form.
when (val data = doc.extractedData) {
is DocumentExtractedData.Mrz -> {
autofillForm(
givenNames = data.givenNames,
surname = data.surname,
documentNumber = data.documentNumber,
dateOfBirth = data.dateOfBirth, // YYMMDD
dateOfExpiry = data.dateOfExpiry, // YYMMDD
nationality = data.nationality, // ISO 3166-1 alpha-3
)
}
null -> { /* extraction not applicable for this document */ }
else -> { /* unexpected type โ handle gracefully */ }
}
}
is DocumentCaptureState.Error -> {
when (state.error.code) {
DocumentCaptureErrorCode.PERMISSION_DENIED -> requestCameraPermission()
DocumentCaptureErrorCode.GLARE_DETECTED -> showHint("Move away from direct light and try again")
DocumentCaptureErrorCode.EDGE_NOT_DETECTED -> showHint("Keep the entire card within the frame")
DocumentCaptureErrorCode.CAPTURE_TIMEOUT -> showRetryDialog()
DocumentCaptureErrorCode.LOW_QUALITY -> showRetryDialog("Image quality too low. Try again in better light.")
else -> showError(state.error.technicalMessage)
}
}
}
}
}
// Step 2 โ start the capture session.
// platformContext must be a FragmentActivity instance on Android.
engine.startCapture(
platformContext = requireActivity(), // must be FragmentActivity
parameters = DocumentCaptureParameters(
documentType = CaptureDocumentType.ID_CARD,
sides = setOf(DocumentSide.FRONT, DocumentSide.BACK),
),
)
8.1.3 Capturing a Passport
Passport capture scans only the bio-page and extracts the MRZ in TD3 format (2 lines ร 44 characters). backImageBytes will be null for passports.
kotlin
engine.startCapture(
platformContext = requireActivity(),
parameters = DocumentCaptureParameters(
documentType = CaptureDocumentType.PASSPORT,
sides = setOf(DocumentSide.BIO_PAGE),
),
)
// In DocumentCaptureState.Success:
// doc.backImageBytes == null
// doc.extractedData == DocumentExtractedData.Mrz (TD3 format)
// mrz.rawLines contains the original 2 OCR lines for downstream DocAuth services.
8.1.4 Capturing a Driving Licence
Driving licence capture reads the PDF417 barcode from the rear of the card. The raw barcode payload (AAMVA or equivalent standard) is provided as a String for forwarding to your backend parser.
kotlin
engine.startCapture(
platformContext = requireActivity(),
parameters = DocumentCaptureParameters(
documentType = CaptureDocumentType.DRIVING_LICENSE,
sides = setOf(DocumentSide.FRONT, DocumentSide.BACK),
),
)
// In DocumentCaptureState.Success:
// doc.extractedData == DocumentExtractedData.Pdf417
// pdf417.rawData == the complete AAMVA barcode string payload
Cancelling a session:
kotlin
// Safe to call at any time โ resets captureState to Idle.
engine.cancelCapture()
8.1.5 iOS / Swift Integration
StateFlow is automatically bridged to Swift's native AsyncSequence via the SKIE framework bundled in the XCFramework. No manual conversion is needed:
swift
let engine = walletSdk.documentCaptureEngine
Task {
for await state in engine.captureState {
switch state {
case is DocumentCaptureStateIdle:
showIdle()
case is DocumentCaptureStateCapturingFront:
showGuidance("Place the front of your ID in the frame")
case is DocumentCaptureStateCapturingBack:
showGuidance("Now flip your ID over")
case is DocumentCaptureStateAnalyzing:
showSpinner()
case let success as DocumentCaptureStateSuccess:
handleDocument(success.document)
case let error as DocumentCaptureStateError:
handleError(error.error.code.name)
default:
break
}
}
}
// Start capture
engine.startCapture(
platformContext: nil, // iOS โ pass nil; context is resolved internally
parameters: DocumentCaptureParameters(
documentType: .idCard,
sides: [.front, .back]
)
)
8.2 MRTD / NFC Passport Reading
The MrtdReaderEngine reads NFC chips embedded in ICAO 9303-compliant passports and travel documents, extracting MRZ data (DG1) and the face biometric image (DG2) using the BAC or PACE authentication protocol.
Read state lifecycle:
text
Idle โ WaitingForChip โ Authenticating โ Reading โ Success
โโ Error
kotlin
// Requires: MRZ data extracted from the document (e.g. via DocumentCaptureEngine first)
val mrtdEngine = sdk.mrtdReaderEngine
viewModelScope.launch {
mrtdEngine.readState.collectLatest { state ->
when (state) {
is MrtdReadState.Idle -> showIdle()
is MrtdReadState.WaitingForChip -> showNfcPrompt("Hold your phone to the passport chip")
is MrtdReadState.Authenticating -> showSpinner("Authenticatingโฆ")
is MrtdReadState.Reading -> showSpinner("Reading chip dataโฆ")
is MrtdReadState.Success -> {
val result = state.result
// Structured MRZ fields
val name = "${result.dg1Data.givenNames} ${result.dg1Data.surname}"
// Raw face biometric image (JPEG) โ pass to your server for matching
uploadFaceImage(result.dg2FaceImage)
}
is MrtdReadState.Error -> showError(state.error.technicalMessage)
}
}
}
// Start reading โ pass MRZ fields extracted from the passport scan
mrtdEngine.startReading(
platformContext = requireActivity(),
parameters = MrtdReadParameters(
documentNumber = mrz.documentNumber, // from DocumentExtractedData.Mrz
dateOfBirth = mrz.dateOfBirth, // YYMMDD format
dateOfExpiry = mrz.dateOfExpiry, // YYMMDD format
),
)
// Cancel if the user navigates away
mrtdEngine.cancelReading()
โ
Tip
Run DocumentCaptureEngine first (see Section 8.1) to extract the MRZ fields from the physical passport. Feed those directly into MrtdReadParameters to avoid manual data entry.
8.3 Liveness Capture
The liveness module (wallet-sdk-liveness) is an optional add-on for selfie capture. It is required when your issuer demands a selfie or biometric verification step during provisioning. Liveness scoring is performed server-side โ the module captures a JPEG frame using the device camera and returns it for upload to your backend.
Add the dependency:
kotlin
implementation("com.credenceid:wallet-sdk-liveness:1.0.0-SNAPSHOT")
โน๏ธImportant lifecycle constraint
The StandardCameraEngine must be instantiated in Activity.onCreate() โ before onStart(). It registers an ActivityResultLauncher internally, and Android requires this registration to happen before the Activity starts.
kotlin
class MyActivity : FragmentActivity() {
// Declare as a property โ created in onCreate before onStart.
private lateinit var livenessEngine: StandardCameraEngine
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
livenessEngine = StandardCameraEngine(this) // โ must be before setContent / onStart
setContent { MyApp(engine = livenessEngine) }
}
}
// In a ViewModel or composable โ trigger capture when the user reaches the selfie step:
viewModelScope.launch {
runCatching { livenessEngine.capture() }
.onSuccess { result ->
val jpeg = (result as LivenessCaptureResult.Standard).jpegBytes
// Send the JPEG to your backend for server-side liveness scoring.
uploadSelfie(jpeg)
}
.onFailure { error ->
// CaptureException is thrown if the user cancels or a hardware error occurs.
showError("Capture failed: ${error.message}")
}
}
The :security module provides Runtime Application Self-Protection (RASP). It monitors the device environment at runtime for threats that could compromise credential security.
Threat catalog:
- Code
- 1001
- Threat
- Root / Jailbreak
- Default behavior
- Blocking
- Code
- 1002
- Threat
- Emulator / Simulator
- Default behavior
- Blocking
- Code
- 1010
- Threat
- Hooking framework (Frida, Xposed)
- Default behavior
- Blocking
- Code
- 1011
- Threat
- Debugger attached
- Default behavior
- Configurable
- Code
- 1020
- Threat
- UI overlay / tapjacking
- Default behavior
- Observe-only
| Code | Threat | Default behavior |
|---|
| 1001 | Root / Jailbreak | Blocking |
| 1002 | Emulator / Simulator | Blocking |
| 1010 | Hooking framework (Frida, Xposed) | Blocking |
| 1011 | Debugger attached | Configurable |
| 1020 | UI overlay / tapjacking | Observe-only |
9.1 Configuration
Initialize RASP once in your Application.onCreate():
kotlin
import com.credenceid.wallet.sdk.security.*
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
val config = RaspConfig.Builder()
.enableRootDetection()
.enableEmulatorDetection()
.enableHookingDetection()
// enforceBlock = false makes debugger detection observe-only in CI/debug builds:
.enableDebuggerDetection(enforceBlock = BuildConfig.DEBUG.not())
.enableOverlayDetection()
.enableSecureWindowFlag() // Applies FLAG_SECURE to every Activity automatically
.build()
// RaspPlatformContext wraps applicationContext โ do not pass `this` directly.
RaspMonitor.initialize(RaspPlatformContext(applicationContext), config)
RaspMonitor.start()
}
}
iOS (AppDelegate / SceneDelegate):
swift
let config = RaspConfigBuilder()
.enableRootDetection()
.enableEmulatorDetection()
.enableHookingDetection()
.enableDebuggerDetection()
.enableOverlayDetection()
.build()
RaspMonitor.shared.initialize(context: RaspPlatformContext(), config: config)
RaspMonitor.shared.start()
9.2 Observing Violations
RaspMonitor.violations is a hot SharedFlow. Collect it in a lifecycle-aware scope:
kotlin
lifecycleScope.launch {
RaspMonitor.violations.collect { violation ->
// Log the threat internally โ never expose raw codes or details to end-users.
logger.warn("RASP violation: ${violation.threat.code}")
when (violation.threat) {
is RaspThreat.RootedDevice -> blockSdkOperations()
is RaspThreat.HookingFrameworkDetected -> blockSdkOperations()
is RaspThreat.EmulatorDetected -> blockSdkOperations()
is RaspThreat.OverlayDetected -> showOverlayWarningDialog()
is RaspThreat.DebuggerAttached -> logAndMonitor()
}
}
}
9.3 Point-in-Time Checks
Run a synchronous check immediately before any sensitive operation (credential signing, biometric prompt):
kotlin
fun onPresentCredentialClicked() {
if (!RaspMonitor.runImmediateCheck()) {
// A blocking threat is active โ abort the operation.
showSecurityError("This operation is not available on this device.")
return
}
// Safe to proceed.
startPresentation()
}
Shutting down RASP:
kotlin
// In Application.onTerminate() or your ViewModel's onCleared():
RaspMonitor.stop()
The :logging module provides two independent log pipelines that satisfy ISO/IEC 27002 Control 8.15 and ISO 18013-5 Annex C audit requirements.
- Pipeline
- DiagnosticLogger
- Purpose
- Developer-facing structured records, filterable by severity
- Mandatory in production
- No
- Pipeline
- AuditLogger
- Purpose
- Compliance-critical immutable audit trail โ always emitted
- Mandatory in production
- Yes
| Pipeline | Purpose | Mandatory in production |
|---|
| DiagnosticLogger | Developer-facing structured records, filterable by severity | No |
| AuditLogger | Compliance-critical immutable audit trail โ always emitted | Yes |
10.1 Setting Up the Logger
Configure the logger once in Application.onCreate() before any concurrent SDK usage:
kotlin
import com.credenceid.wallet.sdk.SdkLogger
import com.credenceid.wallet.sdk.logging.LogLevel
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
SdkLogger.Builder()
.setDiagnosticLogSink { json ->
// Route to Logcat in debug builds โ do not use in production.
if (BuildConfig.DEBUG) Log.d("WalletSDK", json)
}
.setAuditLogSink { json ->
// MANDATORY in production โ route to an append-only, tamper-evident store.
// Examples: write-once Room table, signed log stream, SIEM endpoint.
myAuditRepository.persist(json)
}
.setMinDiagnosticLevel(
if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN
)
.setSdkVersion(BuildConfig.WALLET_SDK_VERSION)
.build()
}
}
โน๏ธAudit log is mandatory in production
Operating without a durable AuditLogSink in production is a regulatory violation under ISO 18013-5 and ISO/IEC 27002. The audit sink must write to an append-only medium โ records must not be modifiable or deletable after writing.
iOS:
swift
SdkLoggerBuilder()
.setDiagnosticLogSink { json in
os_log("%{public}@", log: .default, type: .debug, json)
}
.setAuditLogSink { json in
SecureAuditStore.shared.append(json)
}
.setMinDiagnosticLevel(.info)
.build()
10.2 Log Levels
- Level
- VERBOSE
- Use case
- State transitions, byte counts, retry attempts
- Recommended in production
- โ Never
- Level
- DEBUG
- Use case
- Parsed responses, resolved endpoints
- Recommended in production
- โ Never
- Level
- INFO
- Use case
- SDK initialised, token refreshed, credential stored
- Recommended in production
- โ
Low-volume use
- Level
- WARN
- Use case
- Recoverable anomalies, deprecated API usage
- Recommended in production
- โ
Recommended
- Level
- ERROR
- Use case
- Non-recoverable failures
- Recommended in production
- โ
Always
| Level | Use case | Recommended in production |
|---|
| VERBOSE | State transitions, byte counts, retry attempts | โ Never |
| DEBUG | Parsed responses, resolved endpoints | โ Never |
| INFO | SDK initialised, token refreshed, credential stored | โ
Low-volume use |
| WARN | Recoverable anomalies, deprecated API usage | โ
Recommended |
| ERROR | Non-recoverable failures | โ
Always |
โ
Note
setMinDiagnosticLevel has no effect on audit records โ audit records are always emitted regardless of level.
10.3 Privacy with Redacted<T>
Wrap sensitive values at the point of acquisition to prevent accidental PII leakage in logs:
kotlin
// Wrap immediately when received:
val accessToken: Redacted<String> = oauthResponse.token.redact()
// Safe to log โ always outputs "***REDACTED***", never the actual value:
DiagnosticLogger.d("OidcClient") { "Token acquired: $accessToken" }
// Unwrap only at the single call site that requires the raw value:
httpClient.setBearer(accessToken.unwrap())
// Every unwrap() site must be code-reviewed and justified in a comment.
11Error Handling Patterns
All engine operations return kotlin.Result<T>. The SDK follows a consistent pattern across all engines:
kotlin
// Pattern 1 โ onSuccess / onFailure callbacks (recommended for simple cases)
sdk.provisioningEngine.fetchReadyCredential(uri)
.onSuccess { credential -> updateUi(credential) }
.onFailure { error -> showError(error.message) }
// Pattern 2 โ fold() for exhaustive handling
val result = sdk.storageEngine.deleteCredential(id)
result.fold(
onSuccess = { showToast("Deleted.") },
onFailure = { showError("Failed: ${it.message}") },
)
// Pattern 3 โ structured error handling by type
sdk.provisioningEngine.fetchReadyCredential(uri)
.onFailure { throwable ->
when (throwable) {
is ProvisioningError -> when (throwable.code) {
ProvisioningErrorCode.TIMEOUT -> retryLater() // isRecoverable = true
ProvisioningErrorCode.NETWORK_ERROR -> showNetworkError()
ProvisioningErrorCode.INVALID_OFFER -> showError("Invalid credential offer")
else -> logAndReport(throwable)
}
is CancellationException -> throw throwable // always re-throw coroutine cancellation
else -> logAndReport(throwable)
}
}
PresentationError and DocumentCaptureError also carry an isRecoverable flag:
kotlin
is PresentationState.Error -> {
val err = state.error
if (err.isRecoverable) {
// Offer the user a retry without restarting the full presentation flow.
showRetryButton()
} else {
// Unrecoverable โ close the session and return to the credential list.
showError("${err.code.name}: ${err.technicalMessage}")
navigateBack()
}
}
โน๏ธNever display technicalMessage to end-users
This field is for internal logs and crash reporting only. Map code enum values to user-friendly strings in your own string resources.
The SDK follows Clean Architecture (Hexagonal / Onion) with strict inward dependency flow:
text
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Your Application โ
โ ViewModels, Composables, Activities, Fragments โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ Engine Interfaces (Domain Layer โ commonMain) โ
โ ProvisioningEngine ยท StorageEngine ยท PresentationEngine โ
โ DocumentCaptureEngine ยท MrtdReaderEngine โ
โ โ
โ Domain Models (pure Kotlin โ no framework imports) โ
โ DigitalCredential ยท PresentationState ยท ClaimRequest โฆ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ Engine Implementations (Data Layer) โ
โ androidMain: WalletSdkImpl, WalletPresentmentActivity โฆ โ
โ iosMain: PassportChipReaderProvider โฆ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ Platform โ
โ Android KeyStore ยท iOS Secure Enclave ยท NFC ยท BLE โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Dependency injection: Engines are wired internally via a SdkContainer (service locator pattern) and exposed through WalletSdkImpl. You never instantiate engine classes directly โ always access them via the WalletSdk singleton.
Coroutines: All suspension points are suspend fun and safe to call from any CoroutineScope. Use viewModelScope in ViewModels and lifecycleScope in Activities/Fragments. Never call suspend functions from GlobalScope.
Thread safety: WalletSdk.getInstance() is thread-safe via double-checked locking. StateFlow values are safe to read from any thread.
Running the Sample App
bash
# Install the Android sample app
./gradlew :walletsdksample:installDebug
# Run all unit tests with coverage
./gradlew test
# Generate an HTML coverage report
./gradlew koverHtmlReport
Unit Testing Your Integration Code
The SDK follows the Arrange-Act-Assert pattern. Use MockK for mocking engine interfaces:
kotlin
import io.mockk.*
import com.credenceid.wallet.sdk.domain.engine.StorageEngine
import com.credenceid.wallet.sdk.domain.model.DigitalCredential
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
class WalletViewModelTest {
private val mockStorage = mockk<StorageEngine>()
private val credentialFlow = MutableStateFlow<List<DigitalCredential>>(emptyList())
@BeforeEach
fun setUp() {
every { mockStorage.storedCredentials } returns credentialFlow
}
@Test
fun `credential list updates when new credential is added`() = runTest {
// Arrange
val credential = DigitalCredential(
id = "test-id",
type = "org.iso.18013.5.1.mDL",
provisioningStatus = ProvisioningStatus.VALID,
displayMetadata = mapOf("displayName" to "My Driver's License"),
)
// Act
credentialFlow.emit(listOf(credential))
// Assert
mockStorage.storedCredentials.value.size shouldBeEqualTo 1
mockStorage.storedCredentials.value.first().id shouldBeEqualTo "test-id"
}
}
RASP in CI Environments
Disable emulator detection when running tests in CI (emulators will trigger RASP_ENV_EMULATOR):
kotlin
val config = RaspConfig.Builder()
.enableRootDetection()
.enableEmulatorDetection(enforceBlock = false) // observe-only in CI
.enableHookingDetection()
.enableDebuggerDetection(enforceBlock = false) // observe-only in CI
.build()
Transitive dependencies are missing after adding the SDK
You are likely using @aar notation or the -android-release variant. Use the root coordinate only:
kotlin
// โ
Correct
implementation("com.credenceid:wallet-sdk:1.0.0-SNAPSHOT")
DC API presentation does not trigger (browser flow)
- Verify the device is running Android API 29+ and Chrome 128+.
- Confirm Google Play Services is up to date.
- Ensure at least one credential has been provisioned with
VALID status โ the SDK only registers VALID credentials with Credential Manager.
startCapture() crashes with IllegalStateException
The platformContext parameter must be a FragmentActivity instance, not a ComponentActivity or Context. Pass requireActivity() from a Fragment or this from your FragmentActivity.
StandardCameraEngine.capture() throws ActivityResultLauncher not registered
The engine must be created in Activity.onCreate() before onStart(). Move the StandardCameraEngine(this) call to onCreate().
Audit log records are not persisted
Ensure you have called SdkLogger.Builder().setAuditLogSink { ... }.build() before any concurrent SDK usage. Calling it after the SDK starts processing requests may result in dropped records.
RASP runImmediateCheck() returns false on an emulator during development
Set enforceBlock = false on enableEmulatorDetection() during development and CI builds. This makes violations observable without blocking operations.
iOS framework linker error: duplicate symbols
The framework is a static XCFramework. In Target โ General โ Frameworks, Libraries, and Embedded Content, set it to Do Not Embed.