12iD documentation

12id Android SDK — integration guide

The SDK (com.12id:holder-sdk-android, package com.onetwoid.sdk) turns your Android app into the holder of credentials issued through the 12id platform. Your backend talks to the platform API (the Platform API guide); the SDK talks to it only for two things — the ledger configuration and its mediator invitation — and everything else runs over DIDComm between the wallet on the phone, the mediator and the issuing organisation. No credential or key ever leaves the device.

Requirements

   
Android minSdk 24. Native libraries for arm64-v8a, armeabi-v7a and x86_64 (emulators), 16 KB page aligned. Verified on Android 14 (arm64-v8a) and on x86_64 emulators at API 24, 26, 28 and 36
Android 7.0 (API 24) Its trust store predates Let’s Encrypt’s root (ISRG Root X1). The SDK carries the Let’s Encrypt roots for its own connections, so it works there unchanged. If your app also calls your own server with a Let’s Encrypt certificate, add the same roots to your app’s network security config (the sample does: sample/src/main/res/xml/network_security_config.xml and res/raw/isrg_root_*.pem)
Language Kotlin with coroutines (every SDK call is a suspend function)
Size most of an app’s added size is the three native libraries (Askar, AnonCreds, Indy VDR, in com.12id:holder-sdk-native, which comes with the SDK). Ship an App Bundle or ABI splits so each device downloads one ABI
Push optional but strongly recommended: your backend receives wallet.sync_requested and pushes the app, which calls OneTwoId.sync()

Gradle

The SDK is published to the 12id Maven repository, https://maven.12id.com. Declare it once, next to Google and Maven Central, and add one dependency; the native libraries (com.12id:holder-sdk-native) come with it:

// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven("https://maven.12id.com") { content { includeGroup("com.12id") } }
    }
}

// app/build.gradle.kts
dependencies {
    implementation("com.12id:holder-sdk-android:0.1.0")
}
android {
    splits { abi { isEnable = true; reset(); include("arm64-v8a", "armeabi-v7a", "x86_64"); isUniversalApk = false } }  // or an App Bundle
}

R8/ProGuard rules ship with the SDK (consumer rules); nothing to add.

Initialise once

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        CoroutineScope(Dispatchers.Default).launch {
            OneTwoId.initialize(this@MyApp, OneTwoIdConfig(
                apiBaseUrl = "https://api.12id.example",
                walletTokenProvider = { myBackend.walletToken(installationId) },
                label = "Acme Wallet",                       // what organisations see; fixed at first launch
                logSink = { level, message, error -> Log.println(level.priority(), "12id", message) },
                // minLogLevel = LogLevel.Debug,                // diagnosing only; see below
            ))
        }
    }
}

Everyday calls

Call What it does
connect(invitationUrl) Accepts an organisation’s invitation (the URL behind its QR code). Completion arrives as WalletEvent.ConnectionCompleted
connections() Pairwise connections with organisations
pendingOffers() / acceptOffer(id) / declineOffer(id) Credential offers wait for the user. After acceptOffer the credential is stored automatically when it arrives (WalletEvent.CredentialStored)
credentials() / credential(id) Stored credentials with their attributes and expiresAtEpochSeconds
pendingProofRequests() / proofRequestOptions(id) / respondToProofRequest(id, selection) / declineProofRequest(id) Proof requests wait for the user. proofRequestOptions lists, per requested group, the stored credentials that can answer it; ProofRequestOptions.defaultSelection() picks the first of each. WalletEvent.ProofSent follows
sync() Collects messages queued at the mediator now. Call it from your push handler (below)
reset() Deletes the wallet, its key and the SDK state. Credentials are gone for good; organisations re-issue them
events / addListener WalletEvents: OfferReceived, CredentialStored, ProofRequested, ProofSent, ConnectionCompleted, ExchangeFailed

Every call throws an OneTwoIdException subtype: NotInitialized, WalletTokenRejected, NetworkUnavailable, PlatformError, InvalidInvitation, MediatorUnavailable, LedgerUnavailable, NotFound, InvalidSelection, WalletKeyLost, Internal.

Delivery: foreground polling and push

While the app is in the foreground the SDK polls the mediator every 10 s, so offers and proof requests appear within that time. In the background it stops polling (battery), and messages queue at the mediator. To deliver them:

  1. The platform sends your backend a signed wallet.sync_requested webhook (data.walletId, data.externalId; at most one per wallet per 30 s). It also fires right after first launch and can fire while the app is open: treat it as “sync now”, not “a credential arrived”.
  2. Your backend sends a silent push to that installation with your own FCM setup.
  3. Your push handler calls sync(); do it from WorkManager expedited work so it survives the handler:
class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        OneTwoId.initialize(applicationContext, MyApp.config)   // no-op when already open
        val result = OneTwoId.sync()                            // returns counts; drained=false on timeout
        return Result.success()
    }
}

// FirebaseMessagingService
override fun onMessageReceived(message: RemoteMessage) {
    if (message.data["type"] == "wallet.sync_requested") {
        WorkManager.getInstance(this).enqueueUniqueWork("12id-sync", ExistingWorkPolicy.KEEP,
            OneTimeWorkRequestBuilder<SyncWorker>().setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST).build())
    }
}

sync() works from a process the push just started, using the configuration of the last initialize. Two Android facts to know:

Backups: exclude the wallet

The wallet’s key lives in the Android Keystore and never leaves the device, so wallet files restored onto another device (Auto Backup, device-to-device transfer) cannot be opened: the SDK reports WalletKeyLost and the app must call reset(). Avoid the situation by excluding the wallet from backups. The SDK’s own state is already in noBackupFilesDir; add for the framework’s files:

<!-- res/xml/data_extraction_rules.xml (Android 12+) -->
<data-extraction-rules>
    <cloud-backup>
        <exclude domain="file" path="wallet" />
        <exclude domain="sharedpref" path="aries-framework-kotlin.xml" />
    </cloud-backup>
    <device-transfer>
        <exclude domain="file" path="wallet" />
        <exclude domain="sharedpref" path="aries-framework-kotlin.xml" />
    </device-transfer>
</data-extraction-rules>
<!-- res/xml/backup_rules.xml (Android 11 and older): the same two excludes in <full-backup-content> -->

and reference both from <application android:dataExtractionRules=… android:fullBackupContent=…>.

No recovery, no revocation

Biometric checks (face liveness + match)

Some organisations require a face check before they issue a credential or accept a proof: the user proves they are live and match a reference photo the organisation holds. The SDK knows which offers and requests need it; the camera screen comes from a capture add-on for the organisation’s vendor.

1. Add the add-on for your organisations’ vendor (one artifact per vendor, same version as the SDK):

dependencies {
    implementation "com.12id:holder-sdk-android:0.1.0"
    implementation "com.12id:holder-sdk-android-biometrics-aws:0.1.0"   // AWS Rekognition Face Liveness
}

android {
    compileOptions {
        // Required by the AWS add-on (its Amplify dependencies use Java 8+ APIs).
        coreLibraryDesugaringEnabled true
    }
}
dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' }

The AWS add-on adds about 8 MB to an arm64 release APK. It declares the CAMERA permission and asks for it when the check starts. If your app also uses CameraX directly and fails to compile with “Cannot access class ‘ListenableFuture’”, add implementation 'com.google.guava:guava:33.3.1-android' (the add-on already brings it at runtime).

2. Register it once, next to initialize:

OneTwoId.registerCapture(AwsFaceLivenessCapture())

3. Check before accepting. CredentialOffer.biometric and ProofRequest.biometric are set when a check is required:

val offer = OneTwoId.pendingOffers().first()
if (offer.biometric?.status == BiometricStatus.Pending) {
    when (val outcome = OneTwoId.verifyFace(activity, offer.id)) {   // shows the vendor's screen
        BiometricOutcome.Passed -> OneTwoId.acceptOffer(offer.id)
        is BiometricOutcome.Failed -> showRetry(outcome.attemptsRemaining)
        BiometricOutcome.Locked -> showCancelled()                   // the organisation cancels the exchange
    }
} else {
    OneTwoId.acceptOffer(offer.id)
}

Proof requests work the same way with respondToProofRequest. Each verifyFace call uses one attempt. acceptOffer / respondToProofRequest throw BiometricRequired while the check has not passed. That is a convenience: the platform itself holds the credential (or withholds the proof result from the organisation) until the check passes, whatever the app does.

Exception Meaning
BiometricRequired Call verifyFace first
BiometricCaptureUnavailable(provider) Your app does not include the add-on for this organisation’s vendor
BiometricCancelled The user left the capture screen
BiometricFailed No attempts left, or the check expired

Switching vendor is the organisation’s decision, but your app must ship the new add-on first. The organisation’s console shows how many of your installations report each add-on.

The SDK never sees the result or any image: the vendor streams to the organisation’s own vendor account, and the platform reads the result from there.

Sample app

sdk-android/sample/ is a complete example: SampleApp (initialise + token provider), CustomerBackend (stand-in for your backend), SyncWorker (push path), Compose screens for connecting (paste or scan a QR), offers, credentials and proof requests. Its debug build also has adb-driven test hooks, which 12iD uses for its end-to-end tests; they are not part of the SDK.