
# Getting started: Capacitor (Ionic)
{{<sdk-guides-aside>}}
{{<tags>}}

## **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](/guides/validations_sdk_hybrid_frameworks/). 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](/guides/validations_sdk_shared_logic/). Everything there applies identically to this Capacitor integration.

{{<code lang="typescript" lang_title="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 || '';
{{</code>}}

## **Step 1: Create your Capacitor project**

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

{{<code lang="bash" lang_title="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
{{</code>}}

## **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`**

{{<code lang="typescript" lang_title="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',
)
{{</code>}}

## **Step 3: Call the plugin from your web code**

{{<code lang="typescript" lang_title="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',
  },
})
{{</code>}}

## **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](/guides/validations_sdk_android/) for the newest).

{{<code lang="groovy" lang_title="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'
}
{{</code>}}

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

{{<code lang="groovy" lang_title="Groovy">}}
buildscript {
  dependencies {
    // ... existing ...
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.21"
  }
}
{{</code>}}

### **4.2 Set the minimum SDK version**

In **`android/variables.gradle`**:

{{<code lang="groovy" lang_title="Groovy">}}
ext {
  minSdkVersion = 24
  compileSdkVersion = 36
  targetSdkVersion = 36
  // ... rest of variables ...
}
{{</code>}}

### **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:

{{<code lang="xml" lang_title="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>
{{</code>}}

### **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`**:

{{<code lang="properties" lang_title="Properties">}}
org.gradle.java.home=/path/to/your/java-21-installation
{{</code>}}

### **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.

{{<code lang="kotlin" lang_title="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()
        }
    }
}
{{</code>}}

## **Step 5: iOS setup**

### **5.1 Add the SDK via CocoaPods**

Edit **`ios/App/Podfile`**:

{{<code lang="ruby" lang_title="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
{{</code>}}

Then install the pods:

{{<code lang="bash" lang_title="Shell">}}
cd ios/App && pod install
{{</code>}}

### **5.2 Add the camera permission**

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

{{<code lang="xml" lang_title="XML">}}
<key>NSCameraUsageDescription</key>
<string>Camera access is required for identity verification</string>
{{</code>}}

### **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.

{{<code lang="swift" lang_title="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
    }
}
{{</code>}}

### **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`**:

{{<code lang="swift" lang_title="Swift">}}
import Capacitor

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

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

{{<code lang="xml" lang_title="XML">}}
customClass="CAPBridgeViewController" customModule="Capacitor"
{{</code>}}

to:

{{<code lang="xml" lang_title="XML">}}
customClass="ViewController" customModule="App"
{{</code>}}

### **5.5 Build & run iOS**

{{<code lang="bash" lang_title="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
{{</code>}}

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](/guides/validations_sdk_shared_logic/). The tables below list what this Capacitor plugin accepts.

### **Validation types**

Set `validationType` in the config to one of the following:

{{<table "table w-auto small m-auto text-center table-striped table-bordered">}}
| 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 |
{{</table>}}

### **Validation config parameters**

These are the fields accepted inside the `config` object:

{{<table "table w-auto small m-auto text-center table-striped table-bordered">}}
| 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") |
{{</table>}}

### **UI customization parameters**

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

{{<table "table w-auto small m-auto text-center table-striped table-bordered">}}
| 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 |
{{</table>}}

### **Available countries and document types**

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

{{<table "table w-auto small m-auto text-center table-striped table-bordered">}}
| 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 |
{{</table>}}

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

- [Face validation API](https://dev.truora.com/validators/face_validation_api/)
- [Document validation API](https://dev.truora.com/validators/document_validation_api/)

## **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**

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

### **Handling results**

{{<code lang="typescript" lang_title="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
    }
  }
})
{{</code>}}

## **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](/guides/validations_sdk_shared_logic/) for the full key model.

## **Troubleshooting**

Common issues and how to resolve them:

{{<table "table w-auto small m-auto text-center table-striped table-bordered">}}
| 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. |
{{</table>}}
