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.
// 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.
# 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
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
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).
// 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.3'
implementation 'com.truora:validations.document:1.3.3'
implementation 'com.truora:validations.invoice:1.3.3'
// 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:
buildscript {
dependencies {
// ... existing ...
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.21"
}
}
4.2 Set the minimum SDK version
In android/variables.gradle :
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 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 :
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.
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 :
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', '~> 1.3.3'
pod 'TruoraValidationsSDK', '~> 1.3.3'
end
post_install do |installer|
assertDeploymentTarget(installer)
end
Then install the pods:
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:
<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.
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 :
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:
customClass="CAPBridgeViewController" customModule="Capacitor"
to:
customClass="ViewController" customModule="App"
5.5 Build & run iOS
# 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
true
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. “national-id”, “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
national-id
Brazil
BR
cnh, general-registration
Chile
CL
national-id, foreign-id, driver-license, passport
Colombia
CO
national-id, foreign-id, rut, ppt, passport, identity-card, temporary-national-id
Costa Rica
CR
national-id, foreign-id
El Salvador
SV
national-id, foreign-id, passport
Mexico
MX
national-id, foreign-id, passport
Peru
PE
national-id, foreign-id
Venezuela
VE
national-id
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
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
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.