Getting started: Capacitor (Ionic)

Introduction

This guide shows you how to integrate the Truora Validations native SDKs (Android and iOS) into a Capacitor application using a local plugin.

It is a concrete, end-to-end implementation of the hybrid-frameworks bridge pattern. If you use Ionic or any Capacitor-based stack (Vue, React, or Angular), follow this guide directly. If you use a different hybrid framework, the Kotlin and Swift bridge code here is reusable almost verbatim — only the plugin-registration glue changes.

The integration has two layers:

  • Web layer (TypeScript): a Capacitor plugin definition registered via registerPlugin() that gives you a typed interface for calling native methods and listening for events.
  • Native layer (Kotlin / Swift): platform-specific plugin classes that receive calls from the web layer, configure and launch the Truora SDK, and emit results back through Capacitor’s event system.

Requirements

Platform Requirements

  • Capacitor: 7.0.0 or higher
  • Node.js: 20.19.0+ or 22.12.0+
  • Android: API Level 24 (Android 7.0) or higher, Gradle 8.12+, AGP 8.9+, Java 21
  • iOS: 14.0 or higher, CocoaPods 1.10+, Xcode 15+

The Truora Android SDK supports API 21+ and the iOS SDK supports iOS 13+, but Capacitor 7 itself raises the floor. The versions above are a known-working configuration.

Permissions Required

  • Camera access for document and face capture
  • Internet access for API communication

Prerequisites & Authentication

Both SDKs retrieve their API key through a key provider interface rather than taking it as a plain argument. This avoids hardcoding credentials and lets you fetch a temporary key at runtime. The key must be of type SDK (temporary) — any other key type will result in an error.

How you obtain the key is up to you (backend service, secure storage, or env config during development). In this integration the web layer passes the key into startValidation(), and the native plugin holds it in memory for the SDK to consume.

For the full authentication model — the two-key system (Generator Key + SDK Key), how to mint a temporary SDK key, and shared concepts like userId, language, and UI customization — see the Shared logic guide. Everything there applies identically to this Capacitor integration.

TypeScript
                
// Recommended for production: fetch a temporary SDK key from your backend
const getApiKey = async (): Promise<string> => {
  const response = await fetch('https://your-api.com/truora-key', {
    headers: { Authorization: `Bearer ${userToken}` },
  });
  const { apiKey } = await response.json();
  return apiKey;
};

// Development only: from an environment variable
const getApiKeyDev = (): string => import.meta.env.VITE_TRUORA_API_KEY || '';

            

Step 1: Create your Capacitor project

If you already have a Capacitor project, skip to Step 2.

Shell
                
# Create a new project (Vue, React, or Angular — your choice)
npm create vite@latest my-app -- --template vue-ts
cd my-app
npm install

# Add Capacitor
npm install @capacitor/core @capacitor/cli
npx cap init "My App" com.example.myapp --web-dir dist

# Add platforms
npm install @capacitor/android @capacitor/ios
npx cap add android
npx cap add ios

            

Step 2: Define the TypeScript plugin interface

Create a file in your web source that defines the plugin interface. This is the contract between your web code and the native code.

src/plugins/TruoraValidations.ts

TypeScript
                
import { registerPlugin } from '@capacitor/core'
import type { PluginListenerHandle } from '@capacitor/core'

export interface StartValidationOptions {
  config: {
    validationType: string // "face" | "document" | "document_with_face" | "invoice"
    autocapture: boolean // Auto-capture when face/document is detected
    waitForResults: boolean // Wait for API response before closing
    language: string | null // "es" | "en" | "pt" | null
    country: string | null // Country code (e.g. "CO", "MX") or null
    documentType: string | null // Document type or null
  }
  uiCustomization: {
    logoUrl: string
    surfaceColor: string
    onSurfaceColor: string
    surfaceVariantColor: string
    onSurfaceVariantColor: string
    primaryColor: string
    onPrimaryColor: string
    secondaryColor: string
    onSecondaryColor: string
    errorColor: string
  }
  apiKey: string | null
  accountId: string // Unique user ID in your system
}

export interface PlatformInfoResult {
  applicationId: string
  platform: 'ios' | 'android' | 'unknown'
}

export interface ValidationResultEvent {
  results: string // JSON string of the validation results array
}

export interface LoadingStateEvent {
  loading: boolean
}

export interface TruoraValidationsPlugin {
  startValidation(options: StartValidationOptions): Promise<void>
  getPlatformInfo(): Promise<PlatformInfoResult>
  addListener(
    eventName: 'validationResults',
    listener: (event: ValidationResultEvent) => void,
  ): Promise<PluginListenerHandle>
  addListener(
    eventName: 'loadingStateChanged',
    listener: (event: LoadingStateEvent) => void,
  ): Promise<PluginListenerHandle>
}

export const TruoraValidations = registerPlugin<TruoraValidationsPlugin>(
  'TruoraValidations',
)

            

Step 3: Call the plugin from your web code

TypeScript
                
import { TruoraValidations } from '@/plugins/TruoraValidations'

// 1. Listen for results
TruoraValidations.addListener('validationResults', (event) => {
  const results = JSON.parse(event.results)
  // Each item: { result, validationType, validationId?, validationStatus? }
  // result is: "completed" | "canceled" | "error"
  console.log('Validation results:', results)
})

// 2. Listen for loading-state changes
TruoraValidations.addListener('loadingStateChanged', (event) => {
  console.log('Loading:', event.loading)
})

// 3. Start a validation
await TruoraValidations.startValidation({
  apiKey: 'your-temporary-sdk-api-key',
  accountId: 'unique-user-id',
  config: {
    validationType: 'face', // or "document", "document_with_face", "invoice"
    autocapture: true,
    waitForResults: true,
    language: 'es',
    country: null,
    documentType: null,
  },
  uiCustomization: {
    logoUrl: 'https://your-cdn.com/logo.png',
    primaryColor: '#435AE0',
    onPrimaryColor: '#FFFFFF',
    secondaryColor: '#E8EAFF',
    onSecondaryColor: '#435AE0',
    surfaceColor: '#FFFFFF',
    onSurfaceColor: '#1A1A1A',
    surfaceVariantColor: '#F5F5F5',
    onSurfaceVariantColor: '#666666',
    errorColor: '#FF5454',
  },
})

            

Step 4: Android setup

4.1 Add SDK dependencies

Edit android/app/build.gradle and add the Truora SDK artifacts and the Kotlin plugin. Use the latest published version (the current release is shown below — check the Android SDK guide for the newest).

Groovy
                
// At the top of the file:
apply plugin: 'kotlin-android'

android {
  // ... existing config ...
  compileOptions {
    sourceCompatibility JavaVersion.VERSION_21
    targetCompatibility JavaVersion.VERSION_21
  }
  kotlinOptions {
    jvmTarget = '21'
  }
}

dependencies {
  // ... existing dependencies ...

  // Truora Validations SDK
  implementation 'com.truora:validations.face:1.3.0'
  implementation 'com.truora:validations.document:1.3.0'
  implementation 'com.truora:validations.invoice:1.3.0'

  // Required for JSON serialization
  implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1'
}

            

Make sure the root android/build.gradle has the Kotlin plugin on the classpath:

Groovy
                
buildscript {
  dependencies {
    // ... existing ...
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.21"
  }
}

            

4.2 Set the minimum SDK version

In android/variables.gradle:

Groovy
                
ext {
  minSdkVersion = 24
  compileSdkVersion = 36
  targetSdkVersion = 36
  // ... rest of variables ...
}

            

4.3 Configure the manifest

Edit android/app/src/main/AndroidManifest.xml. Add the permissions and override the FileProvider declaration so it does not conflict with the one the Truora SDK bundles:

XML
                
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools">

  <!-- Camera and internet permissions -->
  <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.CAMERA" />

  <application ...>
    <!-- ... existing activity ... -->

    <!-- Resolve the FileProvider conflict with the Truora SDK -->
    <provider
      android:name="androidx.core.content.FileProvider"
      android:authorities="${applicationId}.fileprovider"
      android:exported="false"
      android:grantUriPermissions="true"
      tools:replace="android:authorities">
      <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths"
        tools:replace="android:resource" />
    </provider>
  </application>
</manifest>

            

4.4 Configure the Java version

If your system default Java is not version 21, point Gradle at a JDK 21 install in android/gradle.properties:

Properties
                
org.gradle.java.home=/path/to/your/java-21-installation

            

4.5 Create the Android plugin

Create android/app/src/main/java/com/example/myapp/TruoraValidationsPlugin.kt (adjust the package to match your applicationId). This is the Android half of the bridge: it implements TruoraAPIKeyGetter, builds the validation, and emits results back to the web layer.

MainActivity requirement: the Capacitor BridgeActivity already extends AppCompatActivity, which the plugin’s load() relies on to create the ValidationHandler. No extra MainActivity change is needed beyond the default Capacitor setup.

Kotlin
                
package com.example.myapp

import android.content.Context
import android.content.ContextWrapper
import android.util.Log
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import com.truora.core.TruoraSDK
import com.truora.core.shared.client.ValidationHandler
import com.truora.entities.Country
import com.truora.entities.DocumentType
import com.truora.shared.models.DocumentValidationConfig
import com.truora.shared.models.FaceValidationConfig
import com.truora.shared.models.InvoiceValidationConfig
import com.truora.shared.models.TruoraLanguage
import com.truora.shared.models.ReferenceFace
import com.truora.shared.models.TruoraValidationCanceled
import com.truora.shared.models.TruoraValidationCapture
import com.truora.shared.models.TruoraValidationCompleted
import com.truora.shared.models.TruoraValidationError
import com.truora.shared.models.UIConfig
import com.truora.shared.models.ValidationResult
import com.truora.interfaces.TruoraAPIKeyGetter
import com.truora.errors.TruoraException

@CapacitorPlugin(name = "TruoraValidations")
class TruoraValidationsPlugin : Plugin(), TruoraAPIKeyGetter {

    companion object {
        private const val TAG = "TruoraValidations"
        private var validationHandler: ValidationHandler? = null
    }

    private var storedApiKey: String? = null

    override fun load() {
        super.load()
        val act = activity as? androidx.appcompat.app.AppCompatActivity ?: return
        validationHandler = ValidationHandler.create(act)
        Log.d(TAG, "ValidationHandler created during plugin load")
    }

    override fun getApiKeyFromSecureStorage(): String {
        return storedApiKey ?: throw IllegalStateException("API key not configured")
    }

    @PluginMethod
    fun getPlatformInfo(call: PluginCall) {
        val result = JSObject()
        result.put("applicationId", context.packageName)
        result.put("platform", "android")
        call.resolve(result)
    }

    @PluginMethod
    fun startValidation(call: PluginCall) {
        val handler = validationHandler
        if (handler == null) {
            call.reject("ValidationHandler not initialized")
            return
        }

        val apiKey = call.getString("apiKey")
        if (apiKey.isNullOrBlank()) {
            call.reject("apiKey is required")
            return
        }
        storedApiKey = apiKey

        val accountId = call.getString("accountId") ?: ""
        val configObj = call.getObject("config") ?: run {
            call.reject("config is required")
            return
        }

        val validationType = configObj.getString("validationType") ?: "face"
        val autocapture = configObj.optBoolean("autocapture", true)
        val waitForResults = configObj.optBoolean("waitForResults", true)
        val language = configObj.optString("language", null)
        val country = configObj.optString("country", null)
        val documentType = configObj.optString("documentType", null)

        try {
            val contextWithApiKey = ApiKeyGetterContext(context, this)
            val builder = TruoraSDK.Validations.Builder(contextWithApiKey, accountId)

            if (!language.isNullOrBlank()) {
                builder.withLanguage(parseLanguage(language))
            }

            val uiObj = call.getObject("uiCustomization")
            if (uiObj != null) {
                builder.withUIConfig { uiBuilder: UIConfig.Builder ->
                    applyUiConfig(uiBuilder, uiObj)
                }
            }

            when (validationType) {
                "face" -> {
                    builder.withValidation { faceBuilder: FaceValidationConfig.Builder ->
                        faceBuilder.useAutocapture(autocapture)
                        faceBuilder.waitForResults(waitForResults)
                        faceBuilder.build()
                    }
                }
                "document" -> {
                    builder.withValidation { docBuilder: DocumentValidationConfig.Builder ->
                        docBuilder.useAutocapture(autocapture)
                        docBuilder.waitForResults(waitForResults)
                        parseCountry(country)?.let { docBuilder.setCountry(it) }
                        parseDocumentType(documentType)?.let { docBuilder.setDocumentType(it) }
                        docBuilder.build()
                    }
                }
                "document_with_face" -> {
                    builder.withValidation { docBuilder: DocumentValidationConfig.Builder ->
                        docBuilder.useAutocapture(autocapture)
                        docBuilder.waitForResults(true)
                        parseCountry(country)?.let { docBuilder.setCountry(it) }
                        parseDocumentType(documentType)?.let { docBuilder.setDocumentType(it) }
                        docBuilder.build()
                    }
                    builder.build(handler)
                    handler.start { docResult ->
                        when (docResult) {
                            is TruoraValidationCompleted<ValidationResult> -> {
                                notifyValidationResult("completed", docResult.value, null)
                                val faceUrl = docResult.value.getFaceReferenceImage()
                                if (faceUrl != null) {
                                    startFaceAfterDocument(handler, faceUrl.toString(), accountId, autocapture, waitForResults, language, uiObj)
                                }
                            }
                            is TruoraValidationError<*> -> {
                                @Suppress("UNCHECKED_CAST")
                                val partial = docResult.validationResult as? ValidationResult
                                notifyValidationResult("error", partial, docResult.exception)
                            }
                            is TruoraValidationCanceled<*> -> {
                                @Suppress("UNCHECKED_CAST")
                                val partial = docResult.validationResult as? ValidationResult
                                notifyValidationResult("canceled", partial, null)
                            }
                            is TruoraValidationCapture<*> -> { }
                        }
                    }
                    call.resolve()
                    return
                }
                "invoice" -> {
                    builder.withValidation { invoiceBuilder: InvoiceValidationConfig.Builder ->
                        invoiceBuilder.waitForResults(waitForResults)
                        parseCountry(country)?.let { invoiceBuilder.setCountry(it) }
                        invoiceBuilder.build()
                    }
                }
            }

            builder.build(handler)

            handler.start { result ->
                when (result) {
                    is TruoraValidationCompleted<ValidationResult> -> {
                        notifyValidationResult("completed", result.value, null)
                    }
                    is TruoraValidationError<*> -> {
                        @Suppress("UNCHECKED_CAST")
                        val partial = result.validationResult as? ValidationResult
                        notifyValidationResult("error", partial, result.exception)
                    }
                    is TruoraValidationCanceled<*> -> {
                        @Suppress("UNCHECKED_CAST")
                        val partial = result.validationResult as? ValidationResult
                        notifyValidationResult("canceled", partial, null)
                    }
                    is TruoraValidationCapture<*> -> { }
                }
            }

            call.resolve()

        } catch (e: TruoraException) {
            Log.e(TAG, "SDK error: ${e.message}", e)
            call.reject(e.message ?: "SDK configuration error", "${e.getCode()}")
        } catch (t: Throwable) {
            Log.e(TAG, "Unexpected error: ${t.message}", t)
            call.reject(t.message ?: "Unknown error")
        }
    }

    private fun notifyValidationResult(
        resultType: String,
        validationResult: ValidationResult?,
        exception: TruoraException?
    ) {
        val resultJson = JSObject()
        resultJson.put("result", resultType)
        resultJson.put("validationType", "validation")
        if (validationResult != null) {
            resultJson.put("validationId", validationResult.id)
            resultJson.put("validationStatus", validationResult.status)
        }
        if (exception != null) {
            resultJson.put("validationStatus", "error: ${exception.message}")
        }

        val eventData = JSObject()
        eventData.put("results", "[$resultJson]")
        notifyListeners("validationResults", eventData)

        val loadingData = JSObject()
        loadingData.put("loading", false)
        notifyListeners("loadingStateChanged", loadingData)
    }

    private fun startFaceAfterDocument(
        handler: ValidationHandler,
        faceUrl: String,
        accountId: String,
        autocapture: Boolean,
        waitForResults: Boolean,
        language: String?,
        uiObj: JSObject?
    ) {
        try {
            val referenceFace = ReferenceFace.from(faceUrl)
            val contextWithApiKey = ApiKeyGetterContext(context, this)
            val faceBuilder = TruoraSDK.Validations.Builder(contextWithApiKey, accountId)

            if (!language.isNullOrBlank()) {
                faceBuilder.withLanguage(parseLanguage(language))
            }
            if (uiObj != null) {
                faceBuilder.withUIConfig { uiBuilder: UIConfig.Builder ->
                    applyUiConfig(uiBuilder, uiObj)
                }
            }

            faceBuilder.withValidation { fb: FaceValidationConfig.Builder ->
                fb.useReferenceFace(referenceFace)
                fb.useAutocapture(autocapture)
                fb.waitForResults(waitForResults)
                fb.build()
            }
            faceBuilder.build(handler)

            handler.start { result ->
                when (result) {
                    is TruoraValidationCompleted<ValidationResult> -> {
                        notifyValidationResult("completed", result.value, null)
                    }
                    is TruoraValidationError<*> -> {
                        @Suppress("UNCHECKED_CAST")
                        val partial = result.validationResult as? ValidationResult
                        notifyValidationResult("error", partial, result.exception)
                    }
                    is TruoraValidationCanceled<*> -> {
                        @Suppress("UNCHECKED_CAST")
                        val partial = result.validationResult as? ValidationResult
                        notifyValidationResult("canceled", partial, null)
                    }
                    is TruoraValidationCapture<*> -> { }
                }
            }
        } catch (e: TruoraException) {
            Log.e(TAG, "Face validation error: ${e.message}", e)
            notifyValidationResult("error", null, e)
        } catch (t: Throwable) {
            Log.e(TAG, "Face validation unexpected error: ${t.message}", t)
            notifyValidationResult("error", null, null)
        }
    }

    private fun applyUiConfig(builder: UIConfig.Builder, uiObj: JSObject): UIConfig {
        uiObj.optStringOrNull("primaryColor")?.let { builder.setPrimaryColor(it) }
        uiObj.optStringOrNull("onPrimaryColor")?.let { builder.setOnPrimaryColor(it) }
        uiObj.optStringOrNull("secondaryColor")?.let { builder.setSecondaryColor(it) }
        uiObj.optStringOrNull("onSecondaryColor")?.let { builder.setOnSecondaryColor(it) }
        uiObj.optStringOrNull("surfaceColor")?.let { builder.setSurfaceColor(it) }
        uiObj.optStringOrNull("onSurfaceColor")?.let { builder.setOnSurfaceColor(it) }
        uiObj.optStringOrNull("surfaceVariantColor")?.let { builder.setSurfaceVariantColor(it) }
        uiObj.optStringOrNull("onSurfaceVariantColor")?.let { builder.setOnSurfaceVariantColor(it) }
        uiObj.optStringOrNull("errorColor")?.let { builder.setErrorColor(it) }
        uiObj.optStringOrNull("logoUrl")?.let { builder.setLogo(it) }
        return builder.build()
    }

    private fun parseCountry(value: String?): Country? {
        if (value.isNullOrBlank()) return null
        return try { Country.valueOf(value.uppercase()) } catch (_: IllegalArgumentException) { null }
    }

    private fun parseDocumentType(value: String?): DocumentType? {
        if (value.isNullOrBlank()) return null
        val normalized = value.lowercase()
        return if (normalized.contains("-")) {
            DocumentType.fromValue(normalized)
        } else {
            try { DocumentType.valueOf(value.uppercase()) } catch (_: IllegalArgumentException) { DocumentType.fromValue(normalized) }
        }
    }

    private fun parseLanguage(lang: String): TruoraLanguage {
        return when (lang.lowercase()) {
            "es", "spanish" -> TruoraLanguage.SPANISH
            "pt", "portuguese" -> TruoraLanguage.PORTUGUESE
            else -> TruoraLanguage.ENGLISH
        }
    }

    private fun JSObject.optStringOrNull(key: String): String? {
        val value = this.getString(key)
        return if (value.isNullOrBlank() || value == "null") null else value
    }

    private class ApiKeyGetterContext(
        base: Context,
        private val apiKeyGetter: TruoraAPIKeyGetter,
    ) : ContextWrapper(base), TruoraAPIKeyGetter {
        override fun getApiKeyFromSecureStorage(): String {
            return apiKeyGetter.getApiKeyFromSecureStorage()
        }
    }
}

            

Step 5: iOS setup

5.1 Add the SDK via CocoaPods

Edit ios/App/Podfile:

Ruby
                
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'

platform :ios, '14.0'
use_frameworks!

install! 'cocoapods', :disable_input_output_paths => true

def capacitor_pods
  pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
  pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
end

target 'App' do
  capacitor_pods

  # Truora Validations SDK
  pod 'TruoraCamera'
  pod 'TruoraValidationsSDK'
end

post_install do |installer|
  assertDeploymentTarget(installer)
end

            

Then install the pods:

Shell
                
cd ios/App && pod install

            

5.2 Add the camera permission

Edit ios/App/App/Info.plist and add the camera usage description inside the <dict> block:

XML
                
<key>NSCameraUsageDescription</key>
<string>Camera access is required for identity verification</string>

            

5.3 Create the iOS plugin

Create ios/App/App/TruoraValidationsPlugin.swift. This is the iOS half of the bridge: it implements TruoraAPIKeyGetter, builds and presents the validation, and notifies the web layer of results.

Swift
                
import Capacitor
import Foundation
import TruoraValidationsSDK

@objc(TruoraValidationsPlugin)
public class TruoraValidationsPlugin: CAPPlugin, CAPBridgedPlugin, TruoraAPIKeyGetter {
    public let identifier = "TruoraValidationsPlugin"
    public let jsName = "TruoraValidations"
    public let pluginMethods: [CAPPluginMethod] = [
        CAPPluginMethod(name: "startValidation", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "getPlatformInfo", returnType: CAPPluginReturnPromise)
    ]

    private var storedApiKey: String?
    private var isValidationInProgress = false

    public func getApiKeyFromSecureLocation() async throws -> String {
        guard let apiKey = storedApiKey, !apiKey.isEmpty else {
            throw NSError(
                domain: "TruoraValidationsPlugin", code: -1,
                userInfo: [NSLocalizedDescriptionKey: "API key not configured"]
            )
        }
        return apiKey
    }

    @objc func getPlatformInfo(_ call: CAPPluginCall) {
        let bundleId = Bundle.main.bundleIdentifier ?? "unknown"
        call.resolve(["applicationId": bundleId, "platform": "ios"])
    }

    @objc func startValidation(_ call: CAPPluginCall) {
        guard let apiKey = call.getString("apiKey"), !apiKey.isEmpty else {
            call.reject("apiKey is required")
            return
        }
        storedApiKey = apiKey

        let accountId = call.getString("accountId") ?? ""
        guard let configObj = call.getObject("config") else {
            call.reject("config is required")
            return
        }

        let validationType = configObj["validationType"] as? String ?? "face"
        let autocapture = configObj["autocapture"] as? Bool ?? true
        let waitForResults = configObj["waitForResults"] as? Bool ?? true
        let language = configObj["language"] as? String
        let country = configObj["country"] as? String
        let documentType = configObj["documentType"] as? String

        guard !isValidationInProgress else {
            call.reject("A validation is already in progress")
            return
        }
        isValidationInProgress = true
        call.resolve()

        Task { @MainActor in
            do {
                var builder = TruoraValidationsSDK.Builder(apiKeyGenerator: self, userId: accountId)

                if let lang = language, !lang.isEmpty, let parsed = Self.parseLanguage(lang) {
                    builder = builder.withLanguage(parsed)
                }
                if let uiObj = call.getObject("uiCustomization") {
                    builder = builder.withConfig { uiConfig in Self.applyUiConfig(uiConfig, from: uiObj) }
                }

                switch validationType {
                case "face":
                    let validation = builder.withValidation { (face: Face) in
                        face.useAutocapture(autocapture).waitForResults(waitForResults)
                    }.build()
                    await self.runValidation(validation)

                case "document":
                    let validation = builder.withValidation { (doc: Document) in
                        var d = doc.useAutocapture(autocapture).waitForResults(waitForResults)
                        if let c = country, !c.isEmpty { d = d.setCountry(c) }
                        if let dt = documentType, !dt.isEmpty { d = d.setDocumentType(dt) }
                        return d
                    }.build()
                    await self.runValidation(validation)

                case "document_with_face":
                    let docValidation = builder.withValidation { (doc: Document) in
                        var d = doc.useAutocapture(autocapture).waitForResults(waitForResults)
                        if let c = country, !c.isEmpty { d = d.setCountry(c) }
                        if let dt = documentType, !dt.isEmpty { d = d.setDocumentType(dt) }
                        return d
                    }.build()

                    let docResult: TruoraValidationResult<ValidationResult> = await withCheckedContinuation { cont in
                        Task { @MainActor in
                            guard let vc = await self.awaitPresentableViewController() else {
                                cont.resume(returning: .canceled(nil))
                                return
                            }
                            await docValidation.start(from: vc) { cont.resume(returning: $0) }
                        }
                    }

                    self.handleResult(docResult)

                    // The SDK only allows ONE validation type per builder. For
                    // document + face, run them sequentially with separate builders,
                    // using the document photo as the face reference.
                    if case .completed(let value) = docResult, let faceUrl = value.getFaceReferenceImage() {
                        let referenceFace = try ReferenceFace.from(faceUrl)
                        let faceBuilder = TruoraValidationsSDK.Builder(apiKeyGenerator: self, userId: accountId)
                        if let lang = language, !lang.isEmpty, let parsed = Self.parseLanguage(lang) {
                            _ = faceBuilder.withLanguage(parsed)
                        }
                        let faceValidation = faceBuilder.withValidation { (face: Face) in
                            face.useReferenceFace(referenceFace)
                                .useAutocapture(autocapture)
                                .waitForResults(waitForResults)
                        }.build()
                        await self.runValidation(faceValidation)
                    } else {
                        self.isValidationInProgress = false
                    }

                case "invoice":
                    let validation = builder.withValidation { (invoice: Invoice) in
                        var inv = invoice.waitForResults(waitForResults)
                        if let c = country, !c.isEmpty { inv = inv.setCountry(c) }
                        return inv
                    }.build()
                    await self.runValidation(validation)

                default:
                    self.isValidationInProgress = false
                    self.notifyError("Unsupported validation type: \(validationType)")
                }
            } catch {
                self.isValidationInProgress = false
                self.notifyError("SDK error: \(error.localizedDescription)")
            }
        }
    }

    @MainActor
    private func runValidation(_ validation: TruoraValidationsSDK.TruoraValidation<some Any>) async {
        guard let vc = await awaitPresentableViewController() else {
            isValidationInProgress = false
            notifyError("Could not find a view controller")
            return
        }
        await validation.start(from: vc) { [weak self] (result: TruoraValidationResult<ValidationResult>) in
            self?.isValidationInProgress = false
            self?.handleResult(result)
        }
    }

    private func handleResult(_ result: TruoraValidationResult<ValidationResult>) {
        switch result {
        case .completed(let vr): notifyResult("completed", vr: vr)
        case .error(let err): notifyResult("error", vr: nil, error: err)
        case .canceled(let vr): notifyResult("canceled", vr: vr)
        }
    }

    private func notifyResult(_ type: String, vr: ValidationResult?, error: TruoraException? = nil) {
        var dict: [String: Any] = ["result": type, "validationType": "validation"]
        if let vr {
            dict["validationId"] = vr.validationId
            dict["validationStatus"] = vr.status.rawValue
        }
        if let error { dict["validationStatus"] = "error: \(error.localizedDescription)" }
        if let data = try? JSONSerialization.data(withJSONObject: [dict]),
           let json = String(data: data, encoding: .utf8) {
            notifyListeners("validationResults", data: ["results": json])
        }
        notifyListeners("loadingStateChanged", data: ["loading": false])
    }

    private func notifyError(_ message: String) {
        notifyResult("error", vr: nil, error: nil)
    }

    private static func applyUiConfig(_ config: UIConfig, from dict: [String: Any]) -> UIConfig {
        var c = config
        if let v = dict["primaryColor"] as? String, !v.isEmpty { c = c.setPrimaryColor(v) }
        if let v = dict["onPrimaryColor"] as? String, !v.isEmpty { c = c.setOnPrimaryColor(v) }
        if let v = dict["secondaryColor"] as? String, !v.isEmpty { c = c.setSecondaryColor(v) }
        if let v = dict["onSecondaryColor"] as? String, !v.isEmpty { c = c.setOnSecondaryColor(v) }
        if let v = dict["surfaceColor"] as? String, !v.isEmpty { c = c.setSurfaceColor(v) }
        if let v = dict["onSurfaceColor"] as? String, !v.isEmpty { c = c.setOnSurfaceColor(v) }
        if let v = dict["surfaceVariantColor"] as? String, !v.isEmpty { c = c.setSurfaceVariantColor(v) }
        if let v = dict["onSurfaceVariantColor"] as? String, !v.isEmpty { c = c.setOnSurfaceVariantColor(v) }
        if let v = dict["errorColor"] as? String, !v.isEmpty { c = c.setErrorColor(v) }
        if let v = dict["logoUrl"] as? String, !v.isEmpty { c = c.setLogo(v) }
        return c
    }

    private static func parseLanguage(_ lang: String) -> TruoraLanguage? {
        switch lang.lowercased() {
        case "es", "spanish": .spanish
        case "pt", "portuguese": .portuguese
        case "en", "english": .english
        default: nil
        }
    }

    @MainActor
    private func awaitPresentableViewController() async -> UIViewController? {
        for _ in 0 ..< 20 {
            if let vc = presentableTopViewController() { return vc }
            try? await Task.sleep(nanoseconds: 100_000_000)
        }
        return bridge?.viewController
    }

    private func presentableTopViewController() -> UIViewController? {
        guard let window = UIApplication.shared.connectedScenes
            .compactMap({ $0 as? UIWindowScene }).flatMap(\.windows)
            .first(where: { $0.isKeyWindow }) else { return nil }
        var top = window.rootViewController
        while let presented = top?.presentedViewController {
            if presented.isBeingDismissed { break }
            top = presented
        }
        guard let vc = top, !vc.isBeingDismissed, !vc.isBeingPresented, vc.view.window != nil else { return nil }
        return vc
    }
}

            

5.4 Register the plugin via a custom ViewController

Capacitor does not auto-discover local plugins on iOS — you must register it manually. Create ios/App/App/ViewController.swift:

Swift
                
import Capacitor

class ViewController: CAPBridgeViewController {
    override open func capacitorDidLoad() {
        bridge?.registerPluginInstance(TruoraValidationsPlugin())
    }
}

            

Then update ios/App/App/Base.lproj/Main.storyboard to use your custom class. Find the viewController element and change:

XML
                
customClass="CAPBridgeViewController" customModule="Capacitor"

            

to:

XML
                
customClass="ViewController" customModule="App"

            

5.5 Build & run iOS

Shell
                
# Build the web assets
npm run build

# Sync with iOS
npx cap sync ios

# Install pods
cd ios/App && pod install

# Open in Xcode
npx cap open ios

            

Then build and run on a physical device from Xcode. Face and document validation require a real camera; the iOS Simulator does not have one.

Configuration Options

For the shared concepts behind these options — accountId/userId, language, and UI customization — see the Shared logic guide. The tables below list what this Capacitor plugin accepts.

Validation types

Set validationType in the config to one of the following:

Type Description
face Captures and validates the user’s face via liveness detection
document Captures and validates an identity document
document_with_face Runs document validation first, then face validation using the document photo as reference
invoice Captures and validates an invoice or utility bill

Validation config parameters

These are the fields accepted inside the config object:

Parameter Type Default Description
validationType string “face” The type of validation to perform
autocapture boolean true Automatically capture when the face/document is detected
waitForResults boolean false Wait for the API response before closing the view and showing the result to the user
language string null UI language: “es”, “en”, or “pt”
country string null Country code for document/invoice (e.g. “CO”, “MX”)
documentType string null Document type (e.g. “nationalId”, “passport”)

UI customization parameters

All fields are optional; anything you omit falls back to the default Truora styling:

Parameter Type Description
primaryColor string Primary brand color (hex, e.g. “#435AE0”)
onPrimaryColor string Text/icon color on primary surfaces
secondaryColor string Secondary brand color
onSecondaryColor string Text/icon color on secondary surfaces
surfaceColor string Background color of surfaces
onSurfaceColor string Text/icon color on surfaces
surfaceVariantColor string Variant background color
onSurfaceVariantColor string Text/icon color on variant surfaces
errorColor string Color for error states
logoUrl string URL to your brand logo image

Available countries and document types

Use the country Code for the country field, and one of the listed values for documentType:

Country Code Supported document types
Argentina AR nationalId
Brazil BR cnh, generalRegistration
Chile CL nationalId, foreignId, driverLicense, passport
Colombia CO nationalId, foreignId, rut, ppt, passport, identityCard, temporaryNationalId
Costa Rica CR nationalId, foreignId
El Salvador SV nationalId, foreignId, passport
Mexico MX nationalId, foreignId, passport
Peru PE nationalId, foreignId
Venezuela VE nationalId
All ALL passport

More in-depth configuration (such as the similarity threshold) is documented in the validations API reference:

Understanding SDK results

Result events

The plugin communicates results through Capacitor events. Listen for:

  • validationResults — emitted when a validation completes, is canceled, or errors. The results field is a JSON string containing an array of result objects.
  • loadingStateChanged — emitted with { loading: false } when the validation flow finishes.

Result object structure

TypeScript
                
interface ValidationResultItem {
  result: 'completed' | 'canceled' | 'error'
  validationType: 'validation'
  validationId?: string // Present on completed / canceled
  validationStatus?: string // "success", "failure", "pending", or "error: ..." on errors
}

            

Handling results

TypeScript
                
TruoraValidations.addListener('validationResults', (event) => {
  const results = JSON.parse(event.results)

  for (const item of results) {
    switch (item.result) {
      case 'completed':
        console.log('Validation ID:', item.validationId)
        console.log('Status:', item.validationStatus)
        if (item.validationStatus === 'success') {
          // User passed validation
        } else if (item.validationStatus === 'failure') {
          // User did not pass
        } else {
          // Status is "pending" — still processing
        }
        break

      case 'canceled':
        console.log('User canceled the validation')
        break

      case 'error':
        console.error('Validation error:', item.validationStatus)
        break
    }
  }
})

            

Important notes & constraints

One validation per builder

Both SDKs enforce a rule: only one validation type per builder instance. Calling withValidation twice on the same builder throws an error. The document_with_face flow is handled by running two sequential validations — document first, then face — each with its own builder. The plugin code in this guide already does this correctly.

Physical device required

Face and document validations require a real camera and will not work on emulators or simulators. This is intentional: our injection-attack security flags emulated environments.

API key security

Never hardcode your API key in client-side code for production. Use a temporary SDK-type key fetched from your backend at runtime. The plugin receives the key from your web layer and passes it to the native SDK. See Shared logic for the full key model.

Troubleshooting

Common issues and how to resolve them:

Issue Solution
Camera not working on Android Verify the CAMERA permission is in AndroidManifest.xml. The SDK handles the runtime permission prompt itself.
Build fails on iOS with missing modules Run cd ios/App && pod install and ensure the deployment target is 14.0+ in both the Podfile and the Xcode project.
“API Key Invalid” error Verify the key is correct, of type sdk (temporary), and not expired.
“UNIMPLEMENTED” error on iOS The plugin is not registered. Confirm ViewController.swift calls bridge?.registerPluginInstance(TruoraValidationsPlugin()) and that Main.storyboard uses customClass="ViewController" / customModule="App".
“ValidationHandler not initialized” on Android MainActivity must extend BridgeActivity (which extends AppCompatActivity); the plugin’s load() casts the activity to AppCompatActivity to create the handler.
“Cannot configure multiple validation types” on Android Caused by calling withValidation() twice on one builder. Use the document_with_face pattern from this guide (separate sequential builders).
FileProvider conflict on Android The Truora SDK bundles its own FileProvider. Add tools:replace="android:authorities" and tools:replace="android:resource" to your manifest’s <provider> / <meta-data> as shown in step 4.3.