SDK de Flutter
dependencies:
veridia_sdk:
git:
url: https://github.com/EielCorp/veridia-sdk-flutter.git
ref: v0.4.3
El paquete es veridia_sdk, versión actual 0.4.3. Dart 3.10.3+, Flutter 3.38.4+.
Veridia es una plataforma empresarial y el SDK se licencia por cliente, así que el paquete se distribuye desde un repositorio privado en vez de publicarse abiertamente. Lo único público es esta documentación.
Mandá tu usuario de GitHub a tu contacto en Veridia — la misma persona que te emitió la clave de API — y te damos acceso de lectura. Después de eso el snippet de arriba resuelve con un flutter pub get normal.
Fijá un tag, nunca una rama. ref: main vuelve a resolver en cada pub get y te movería la integración sin cambio de versión. En CI usá la forma SSH, para que ningún token termine en el archivo:
url: git@github.com:EielCorp/veridia-sdk-flutter.git
Versiones anteriores de esta página decían Flutter 3.27 / Dart 3.6. Eso lo subestimaba en cerca de un año: el plugin camera hoy exige Dart 3.10.3 / Flutter 3.38.4, así que en Flutter 3.27–3.37 el pub get falla con un error de resolución transitiva que no nombra la causa real. Nada se degrada en silencio — o resuelve o no resuelve.
Este es el único SDK de Veridia que captura imágenes. Los otros tres son clientes de API. Se encarga del permiso de cámara, la captura, los chequeos de calidad, la detección de rostro en el dispositivo y la subida, y te entrega un verificationId cuando el pipeline ya arrancó.
Integración rápida
import 'package:veridia_sdk/veridia_sdk.dart';
VeridiaFlow(
config: const VeridiaConfig(
publishableKey: 'qv_pub_your_key_here',
userRef: 'your_internal_user_id',
country: 'GT',
documentType: DocumentType.dni,
locale: VeridiaLocale.es,
),
onComplete: (result) {
// La verificación fue ENVIADA. Esto no es un veredicto.
sendToYourBackend(result.verificationId);
},
onError: (error) {
reportToUser(error.code, error.message);
},
)
Esa es toda la integración. VeridiaFlow es un widget normal de Flutter — navegá hacia él, empujalo como ruta de pantalla completa, embebelo en una pestaña.
Es de un solo uso. Descartalo después de completarse y construí una instancia nueva si el usuario necesita reintentar.
VeridiaFlow
VeridiaFlow({
required VeridiaConfig config,
void Function(VeridiaResult result)? onComplete,
void Function(VeridiaError error)? onError,
ThemeData? theme,
})
VeridiaConfig
| Campo | Obligatorio | Por defecto | Notas |
|---|---|---|---|
publishableKey | sí | — | qv_pub_... o qv_pubt_... |
apiBase | no | https://api.xxuxe.online | Sobreescribilo solo para un despliegue regional |
userRef | no | — | Tu id de usuario. Se devuelve en el webhook |
country | no | — | ISO 3166-1 alpha-2 (GT, MX, BR, …) |
documentType | no | — | Si es null, el SDK le pregunta al usuario |
submittedFullName | no | — | Comparación difusa contra el documento, del lado del servidor |
requireDocBack | no | sigue a documentType | Ver abajo |
locale | no | VeridiaLocale.en | en / es / pt |
activeLiveness | no | false | Corre el reto frente a la cámara. Ver abajo |
Nueve campos, y esa es toda la superficie. accentColor no está acá — el tema va por el parámetro theme de VeridiaFlow, no por la config.
activeLiveness
Apagado por defecto. Poniéndolo en true, después de la selfie el SDK corre un reto corto: el servidor emite una secuencia de poses impredecible, el SDK captura cuatro ráfagas de frames contra un ancla, y el servidor verifica que las respuestas coincidan con lo que pidió. La app no juzga nada por su cuenta — captura y sube.
Lo que eso te da es interactividad: la persona reaccionó en tiempo real a una secuencia que nadie podía conocer de antemano, que es justamente lo que un video pregrabado o una imagen inyectada no pueden hacer. Le cuesta al usuario unos quince segundos.
El reto no establece que lo que está frente a la cámara tenga profundidad, y Veridia no lo trata como si lo hiciera. Un caso que cruza el umbral de aprobación solo gracias al reto se deriva a revisión humana en vez de aprobarse solo.
Prender activeLiveness entonces alarga el flujo y, en el margen, manda más casos a revisión, no menos. Prendelo cuando querés esa evidencia extra en el registro y podés absorber eso; dejalo apagado si tu prioridad es un embudo corto.
El mismo reto está disponible en el widget web.
DocumentType es dni, passport, driversLicense, nationalId, other. El enum de Dart es camelCase; se serializa al snake_case de la API (drivers_license, national_id) por vos.
requireDocBack
Si lo dejás en null, el SDK lo deriva:
documentType: DocumentType.passport→false. Los pasaportes son de una sola página.- Cualquier otro
documentType→true. documentTypeen null →true.
Así que el valor por defecto está activado para todo salvo un pasaporte. Si esperabas un flujo de dos capturas (frente + selfie) y te aparecieron tres pantallas, este es el motivo. Poné requireDocBack: false explícitamente para forzarlo a apagado.
VeridiaResult
class VeridiaResult {
String verificationId; // "vf_abc..."
VerificationStatus status; // queued / processing / completed
VerificationVerdict? verdict; // null en este punto
}
onComplete se dispara cuando las imágenes se subieron y submit fue aceptado. El pipeline todavía no corrió. verdict es null, y status no dice nada sobre la persona — es el estado del trabajo.
GET /v1/verify/{id} requiere una clave secreta. VeridiaConfig acepta únicamente una publishableKey, deliberadamente.
La única forma de hacer que el polling funcione desde adentro de la app sería meter una clave qv_sec_* en el binario — y un APK o un IPA se desempaqueta en minutos. La clave extraída no lee solo el resultado de ese usuario: lee los resultados KYC de todos los clientes de tu tenant, incluidos los campos de identidad extraídos. Tratá cualquier sugerencia de hacer polling desde un cliente móvil como un error.
Mandá result.verificationId a tu propio backend y resolvé el resultado ahí: recibí el webhook, o llamá a GET /v1/verify/{id} del lado del servidor con tu clave secreta.
El webhook es además el único canal que lleva el resultado de un caso que revisó una persona, que puede caer mucho después de que el usuario haya cerrado tu app.
onComplete: (result) async {
// Tu servidor registra el verificationId contra este usuario y espera
// el webhook. Acá no se decide nada sobre el resultado.
await api.post('/kyc/started', {
'verificationId': result.verificationId,
'userId': currentUser.id,
});
showPendingScreen();
}
Seteá userRef en la config y el webhook te lo devuelve, lo que te ahorra ese mapeo. Si no lo seteás, el verificationId es el único correlador y tenés que guardarlo vos.
VeridiaError
class VeridiaError implements Exception {
VeridiaErrorCode code;
String message;
Map<String, Object?>? detail;
}
detail se completa cuando el error se originó en la API, y en un caso del lado del cliente: un cameraDenied que ocurre después de que el sistema dejó de preguntar lleva {'permanently_denied': true}. Ese es el caso en que el SDK ofrece Abrir Ajustes en vez de un reintento, porque en iOS el diálogo del sistema aparece una sola vez por instalación.
Once códigos de error, que coinciden uno a uno con el contrato del widget web:
| Enum de Dart | wireValue | Cuándo |
|---|---|---|
cameraDenied | camera_denied | El usuario rechazó el permiso de cámara |
cameraUnavailable | camera_unavailable | No hay cámara usable en el dispositivo |
noFaceInSelfie | no_face_in_selfie | La detección de rostro en el dispositivo no encontró nada |
blurryImage | blurry_image | Falló el chequeo de nitidez |
uploadFailed | upload_failed | Un PUT no tuvo éxito |
apiUnreachable | api_unreachable | Fallo de red al alcanzar Veridia |
invalidApiKey | invalid_api_key | Clave faltante, malformada o revocada |
insufficientCredits | insufficient_credits | Saldo del tenant agotado (HTTP 402) |
rateLimited | rate_limited | HTTP 429 |
userCancelled | user_cancelled | El usuario se salió del flujo |
internalError | internal_error | Cualquier cosa sin clasificar |
code.wireValue te da el string en snake_case, que es la forma que hay que loguear y comparar contra los códigos del widget web.
Dos de estos son mucho más comunes que el resto en producción y es fácil olvidarse de manejarlos: cameraDenied (un prompt de permiso que el usuario rechazó, muchas veces de forma permanente) y userCancelled (el usuario apretó atrás). Ninguno es un fallo de tu integración, y los dos necesitan un camino de UI real — un flujo de KYC abandonado es el resultado más frecuente de cualquier embudo de onboarding.
También se exportan clases de excepción tipadas — CameraDeniedException, UserCancelledException, UploadFailedException, RateLimitException, AuthenticationException, PaymentException, y otras — si preferís atrapar en vez de hacer switch.
Los reintentos HTTP no se aplican automáticamente dentro del flujo de captura. Los fallos afloran a través de onError para que vos decidas.
Configuración por plataforma
Android
En android/app/build.gradle (o .kts):
android {
compileSdk 36
defaultConfig {
// Dejalo en el default de Flutter. En Flutter 3.38+ resuelve a 24 y un
// valor más bajo se reescribe igual.
targetSdk 36
}
}
El piso real de Android es API 24 (Android 7.0). Versiones anteriores de esta página decían 21; las dependencias sí permiten 21, pero Flutter 3.38+ pone minSdk en 24 por defecto y reescribe los valores menores, así que 24 es lo que efectivamente publicás salvo que lo pises a propósito.
No hace falta nada más — ninguna configuración de ML Kit ni de CameraX de tu lado. Este es el camino contra el que compilamos un APK de release real, con R8 incluido, y que cumple el requisito de page-size de 16 KB de Google Play.
En android/app/src/main/AndroidManifest.xml, dentro de <manifest>:
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-feature android:name="android.hardware.camera" android:required="true"/>
iOS
Tres cosas, y ninguna es opcional — acá es donde se traban las integraciones.
1. Subí el deployment target en los dos lados
El platform del Podfile y el IPHONEOS_DEPLOYMENT_TARGET del proyecto de Xcode son dos ajustes distintos, y CocoaPods los compara. Si subís solo el Podfile, el pod install se corta con:
The platform of the target `Runner` (iOS 13.0) is not compatible with
`GoogleMLKit/FaceDetection`, which requires iOS 15.5
En Xcode: elegí el target Runner → Build Settings → iOS Deployment Target → 15.5. Hacelo también a nivel de proyecto, y revisá todas las configuraciones, incluida Profile. La plantilla de Flutter arranca las apps nuevas bastante por debajo de esto, así que este paso aplica prácticamente a cualquier app existente.
2. ios/Podfile
# Google ML Kit se niega a instalarse por debajo de 15.5, y entra por DOS pods
# distintos — así que subir un solo plugin no alcanza para volver a derivar
# este número:
# google_mlkit_face_detection 0.13.2 -> GoogleMLKit/FaceDetection ~> 9.0.0
# google_mlkit_commons 0.11.1 -> MLKitVision ~> 10.0.0
# Versiones anteriores de esta página decían 12.0; con 12.0 el `pod install`
# aborta y la app nunca compila.
platform :ios, '15.5'
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
# Incluí el permiso de cámara en la compilación de permission_handler.
# En iOS TODOS los permisos se compilan afuera por defecto, y sin esta
# macro Permission.camera.request() devuelve "denied" sin mostrar ningún
# diálogo del sistema — el flujo muere en `cameraDenied` y parece que el
# usuario lo rechazó.
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
'$(inherited)',
'PERMISSION_CAMERA=1',
]
# Algunos pods transitivos todavía apuntan a 12.0 y fallarían contra ML Kit.
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.5'
end
end
end
El iPhone más viejo soportado es entonces el 6s / SE (1ª gen). El 5s, el 6 y el 6 Plus llegan hasta iOS 12 y no pueden correr ML Kit.
El camino de Android está verificado compilando y corriendo un APK de release real. La configuración de iOS está derivada de los manifiestos de dependencias y de la app de ejemplo de este repositorio, y no la compilamos ni la corrimos en un iPhone nosotros. Todo lo de esta página está apoyado en un podspec o un plist que leímos; nada está apoyado en una compilación que haya ocurrido.
Si te encontrás con fricción en iOS, avisanos — preferimos encontrarla con vos antes que dejarte encontrarla solo.
3. ios/Runner/Info.plist
<key>NSCameraUsageDescription</key>
<string>Necesitamos acceso a tu cámara para verificar tu identidad
(foto del documento + selfie).</string>
La revisión de la App Store rechaza textos genéricos como "Se requiere acceso a la cámara". Sé específico sobre el caso de uso. Este texto lo lee tu usuario, así que localizalo en <idioma>.lproj/InfoPlist.strings — la UI del SDK habla español y portugués, pero el diálogo de permiso del sistema usa tu bundle, no el nuestro, y si no va a salir en inglés delante de un usuario hispanohablante.
Manifiesto de privacidad. Desde mayo de 2024 Apple exige que toda app de iOS incluya un PrivacyInfo.xcprivacy que declare el uso de APIs sensibles. El SDK de Veridia es Dart puro y no publica su propio framework, así que el manifiesto va en tu app, no en el SDK — es tu responsabilidad escribirlo y mantenerlo al día, y Veridia no provee uno. Ver la documentación de manifiestos de privacidad de Apple.
Dos cosas que solo muerden al subir la app
Ninguna afecta la compilación, y las dos son más fáciles de arreglar ahora que durante un release.
ITSAppUsesNonExemptEncryption. Si falta, App Store Connect hace una pregunta de cumplimiento de exportación en cada subida, TestFlight incluido. A Veridia se llega solo por HTTPS, que está exento:
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
iPad con una sola orientación se rechaza. Si tu target declara soporte de iPad (TARGETED_DEVICE_FAMILY = "1,2", el valor por defecto de Flutter) mientras UISupportedInterfaceOrientations~ipad permite solo vertical, la subida falla la validación con ITMS-90474: la multitarea de iPad exige las cuatro orientaciones. O soportás las cuatro en iPad, o sacás iPad de la familia de dispositivos. Nuestra app de ejemplo es solo iPhone, a propósito — la UI de captura encuadra un documento y una cara contra una guía vertical fija, y nunca se miró un layout de iPad.
NSMicrophoneUsageDescription. El SDK nunca graba audio — cada CameraController que crea pasa enableAudio: false. Pero el plugin camera enlaza la API de audio de Apple igual, y el escaneo de subida de Apple lee el binario y no el grafo de llamadas, así que el aviso puede aparecer en una app que nunca abre el micrófono. Declarar la clave no cuesta nada: nunca se muestra ningún diálogo, porque nadie lo pide.
Qué corre en el dispositivo
- Permiso de cámara y captura
- Reducción de escala a 1600×1200 como máximo (nunca amplía)
- Codificación JPEG con calidad 85
- Nitidez, con tres operadores independientes — varianza laplaciana, Tenengrad (Sobel) y Brenner — aprobada por votación y no por un único número
- Chequeo de brillo por luminancia media
- Detección de rostro en el dispositivo para la selfie, vía Google ML Kit
- La captura de prueba de vida activa, si la habilitaste
- Subida de las imágenes
Los umbrales de nitidez difieren según la superficie, y por eso una selfie que reprobaría como documento igual pasa. Un documento tiene que superar dos de los tres operadores; una selfie uno solo, y a niveles mucho más bajos. Las caras son legítimamente más suaves que un texto impreso, y sostener el teléfono con el brazo estirado no es lo mismo que fotografiar una tarjeta apoyada en una mesa — un solo umbral para ambos rechaza usuarios reales.
Para el documento los operadores además se corren de nuevo sobre la región del documento en vez del frame entero. Promediar sobre una foto que es mayormente escritorio diluye una tarjeta perfectamente nítida hasta reprobarla.
Todo lo demás — OCR, parseo de MRZ, comparación facial, detección de brillo y moiré, comparación difusa de nombres, puntaje de confianza y el veredicto — corre del lado del servidor. El SDK no hace ninguna afirmación propia de anti-spoofing; el backend es la fuente de verdad para approved / review / rejected.
Subidas
Las imágenes no van a object storage prefirmado. Cada slot de subida que devuelve init apunta a un endpoint de Veridia autenticado por X-Veridia-Upload-Token, una credencial de vida corta por verificación que viaja en los propios headers del slot. El SDK reenvía esos headers tal cual, que es lo que hace que la subida tenga éxito.
Esto te importa solo si estás escribiendo reglas de firewall de egreso: los bytes van a api.xxuxe.online, no a un host de almacenamiento. Todavía podés ver "presigned R2 upload" en el README del paquete o en su diagrama de flujo — esa redacción está desactualizada; el código es correcto.
Flujo
idle
↓ el usuario presiona Start
requestingCamera permiso + cámara trasera
↓
captureDocFront ↔ reviewDocFront repetir / confirmar
↓ (si requireDocBack)
captureDocBack ↔ reviewDocBack repetir / confirmar
↓ (cambio a cámara frontal)
captureSelfie ↔ reviewSelfie repetir / confirmar
↓ (solo si activeLiveness: true)
challenge ancla, y despues poses del servidor
↓
uploading PUT de cada imagen
↓
submitting POST /v1/verify/submit
↓
done ✓ se dispara onComplete
Error desde cualquier punto → estado de error + onError
Verificación de webhooks
El paquete exporta un WebhookVerifier, pero un webhook se entrega a un servidor, no a un teléfono: una app no tiene URL estable y no puede guardar el secreto de firma. Verificá los webhooks en tu backend con el SDK de JavaScript, Python o PHP, o con el esquema HMAC documentado en el lenguaje en que corra.
Qué sigue
- Webhooks — cómo llega realmente el veredicto hasta vos
- Widget — el equivalente en navegador de este SDK
- SDK de JavaScript · SDK de Python · SDK de PHP