Flutter SDK
dependencies:
veridia_sdk:
git:
url: https://github.com/EielCorp/veridia-sdk-flutter.git
ref: v0.4.3
The package is veridia_sdk, current version 0.4.3. Dart 3.10.3+, Flutter 3.38.4+.
Veridia is an enterprise platform and the SDK is licensed per customer, so the package is distributed from a private repository rather than published openly. Only this documentation is public.
Send your GitHub username to your Veridia contact — the same person who issued your API key — and we grant read access. After that the snippet above resolves with a normal flutter pub get.
Pin a tag, never a branch. ref: main re-resolves on every pub get and would move your integration without a version bump. In CI, use the SSH form so no token ends up in the file:
url: git@github.com:EielCorp/veridia-sdk-flutter.git
Earlier revisions of this page said Flutter 3.27 / Dart 3.6. That understated it by about a year: the camera plugin now requires Dart 3.10.3 / Flutter 3.38.4, so on Flutter 3.27–3.37 pub get fails with a transitive version-solve error that does not name the real cause. Nothing degrades silently — either it resolves or it does not.
This is the only Veridia SDK that captures images. The other three are API clients. It handles camera permission, capture, quality checks, on-device face detection and upload, and hands you a verificationId when the pipeline has been started.
Quick integration
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) {
// The verification has been SUBMITTED. This is not a verdict.
sendToYourBackend(result.verificationId);
},
onError: (error) {
reportToUser(error.code, error.message);
},
)
That is the whole integration. VeridiaFlow is a normal Flutter widget — navigate to it, push it as a fullscreen route, embed it in a tab.
It is single-use. Dispose it after completion and build a new instance if the user needs to retry.
VeridiaFlow
VeridiaFlow({
required VeridiaConfig config,
void Function(VeridiaResult result)? onComplete,
void Function(VeridiaError error)? onError,
ThemeData? theme,
})
VeridiaConfig
| Field | Required | Default | Notes |
|---|---|---|---|
publishableKey | yes | — | qv_pub_... or qv_pubt_... |
apiBase | no | https://api.xxuxe.online | Override only for a regional deployment |
userRef | no | — | Your user id. Echoed back in the webhook |
country | no | — | ISO 3166-1 alpha-2 (GT, MX, BR, …) |
documentType | no | — | If null, the SDK asks the user |
submittedFullName | no | — | Fuzzy-matched against the document server-side |
requireDocBack | no | follows documentType | See below |
locale | no | VeridiaLocale.en | en / es / pt |
activeLiveness | no | false | Runs the on-camera challenge. See below |
Nine fields, and that is the whole surface. There is no accentColor here — theming goes through the theme parameter of VeridiaFlow, not the config.
activeLiveness
Off by default. Set it to true and, after the selfie, the SDK runs a short challenge: the server issues an unpredictable pose sequence, the SDK captures four bursts of frames against an anchor, and the server checks that the responses match what it asked for. The app makes no judgement of its own — it captures and uploads.
What that buys you is interactivity: the subject reacted in real time to a sequence nobody could know in advance, which is what a pre-recorded video or a statically injected image cannot do. It costs the user about fifteen seconds.
The challenge does not establish that the thing in front of the camera has depth, and Veridia does not treat it as if it did. A case that clears the approval threshold only because of the challenge is routed to human review rather than auto-approved.
Turning activeLiveness on therefore makes the flow longer and, on the margin, sends more cases to review — not fewer. Turn it on when you want the extra evidence on the record and can absorb that; leave it off if your priority is a short funnel.
The same challenge is available in the web widget.
DocumentType is dni, passport, driversLicense, nationalId, other. The Dart enum is camelCase; it serialises to the API's snake_case (drivers_license, national_id) for you.
requireDocBack
If you leave it null, the SDK derives it:
documentType: DocumentType.passport→false. Passports are single-page.- Any other
documentType→true. documentTypenull →true.
So the default is on for everything except a passport. If you were expecting a two-capture flow (front + selfie) and got three screens, this is why. Set requireDocBack: false explicitly to force it off.
VeridiaResult
class VeridiaResult {
String verificationId; // "vf_abc..."
VerificationStatus status; // queued / processing / completed
VerificationVerdict? verdict; // null at this point
}
onComplete fires when the images have been uploaded and submit has been accepted. The pipeline has not run yet. verdict is null, and status says nothing about the person — it is the state of the job.
GET /v1/verify/{id} requires a secret key. VeridiaConfig accepts only a publishableKey, deliberately.
The only way to make polling work from inside the app would be to ship a qv_sec_* key in the binary — and an APK or IPA is unpacked in minutes. The extracted key does not just read that one user's result: it reads the KYC outcomes of every customer in your tenant, including the extracted identity fields. Treat any suggestion to poll from a mobile client as a mistake.
Send result.verificationId to your own backend and resolve the outcome there: receive the webhook, or call GET /v1/verify/{id} server-side with your secret key.
The webhook is also the only channel that carries the outcome of a case a human reviewed, which can land long after the user closed your app.
onComplete: (result) async {
// Your server records the verificationId against this user and waits
// for the webhook. Nothing about the outcome is decided here.
await api.post('/kyc/started', {
'verificationId': result.verificationId,
'userId': currentUser.id,
});
showPendingScreen();
}
Set userRef in the config and the webhook echoes it back, which spares you that mapping. If you do not set it, the verificationId is the only correlator and you must store it yourself.
VeridiaError
class VeridiaError implements Exception {
VeridiaErrorCode code;
String message;
Map<String, Object?>? detail;
}
detail is populated when the error originated in the API, and in one client-side case: a cameraDenied raised after the OS stopped asking carries {'permanently_denied': true}. That is the case where the SDK offers Open Settings instead of a retry, because on iOS the system prompt appears exactly once per install.
Eleven error codes, matching the web widget's contract one for one:
| Dart enum | wireValue | When |
|---|---|---|
cameraDenied | camera_denied | User refused the camera permission |
cameraUnavailable | camera_unavailable | No usable camera on the device |
noFaceInSelfie | no_face_in_selfie | On-device face detection found nothing |
blurryImage | blurry_image | Sharpness check failed |
uploadFailed | upload_failed | A PUT did not succeed |
apiUnreachable | api_unreachable | Network failure reaching Veridia |
invalidApiKey | invalid_api_key | Key missing, malformed or revoked |
insufficientCredits | insufficient_credits | Tenant balance exhausted (HTTP 402) |
rateLimited | rate_limited | HTTP 429 |
userCancelled | user_cancelled | User backed out of the flow |
internalError | internal_error | Anything unclassified |
code.wireValue gives the snake_case string, which is the form to log and to compare against the web widget's codes.
Two of these are far more common than the rest in production and are easy to forget to handle: cameraDenied (a permission prompt the user declined, often permanently) and userCancelled (the user pressed back). Neither is a failure of your integration, and both need a real UI path — an abandoned KYC flow is the single most frequent outcome of any onboarding funnel.
Typed exception classes are exported too — CameraDeniedException, UserCancelledException, UploadFailedException, RateLimitException, AuthenticationException, PaymentException, and others — if you prefer catching over switching.
HTTP retries are not applied automatically inside the capture flow. Failures surface through onError for you to decide about.
Platform setup
Android
In android/app/build.gradle (or .kts):
android {
compileSdk 36
defaultConfig {
// Leave this at Flutter's default. On Flutter 3.38+ it resolves to 24
// and a lower value is rewritten anyway.
targetSdk 36
}
}
The real Android floor is API 24 (Android 7.0). Earlier revisions of this page said 21; the dependencies do allow 21, but Flutter 3.38+ defaults minSdk to 24 and rewrites lower values, so 24 is what you actually ship unless you deliberately override it.
Nothing else is required — no ML Kit or CameraX configuration of your own. This is the path we build a real release APK against, R8 included, and it satisfies Google Play's 16 KB page-size requirement.
In android/app/src/main/AndroidManifest.xml, inside <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
Three things, and none of them is optional — this is where integrations get stuck.
1. Raise the deployment target in both places
The Podfile's platform and the Xcode project's IPHONEOS_DEPLOYMENT_TARGET are two different settings, and CocoaPods compares them. Raise only the Podfile and pod install stops with:
The platform of the target `Runner` (iOS 13.0) is not compatible with
`GoogleMLKit/FaceDetection`, which requires iOS 15.5
In Xcode: select the Runner target → Build Settings → iOS Deployment Target → 15.5. Do it on the project as well as the target, and check all configurations, including Profile. Flutter's own template starts new apps well below this, so this step applies to essentially every existing app.
2. ios/Podfile
# Google ML Kit refuses to install below 15.5, and it arrives through two
# separate pods — so bumping one plugin is not enough to re-derive this number:
# google_mlkit_face_detection 0.13.2 -> GoogleMLKit/FaceDetection ~> 9.0.0
# google_mlkit_commons 0.11.1 -> MLKitVision ~> 10.0.0
# Earlier revisions of this page said 12.0; with 12.0 `pod install` aborts and
# the app never builds.
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|
# Opt the camera permission INTO the permission_handler build. Every iOS
# permission is compiled OUT by default, and without this macro
# Permission.camera.request() returns "denied" with no system dialog at
# all — the flow dies at `cameraDenied` and looks like the user refused.
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
'$(inherited)',
'PERMISSION_CAMERA=1',
]
# Some transitive pods still default to 12.0 and would fail against ML Kit.
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.5'
end
end
end
The oldest supported iPhone is therefore the 6s / SE (1st gen). The 5s, 6 and 6 Plus top out at iOS 12 and cannot run ML Kit at all.
The Android path above is verified by building and running a real release APK. The iOS configuration is derived from the dependency manifests and from the example app in this repository, and has not been compiled or run on an iPhone by us. Everything on this page is grounded in a podspec or a plist we read; none of it is grounded in a build that happened.
If you hit friction on iOS, tell us — we would rather find it with you than have you find it alone.
3. ios/Runner/Info.plist
<key>NSCameraUsageDescription</key>
<string>We need access to your camera to verify your identity
(document photo + selfie).</string>
App Store review rejects generic strings like "Camera access required". Be specific about the use case. This string is what your user reads, so localise it in <lang>.lproj/InfoPlist.strings — the SDK's own UI speaks Spanish and Portuguese, but the system permission dialog uses your bundle, not ours, and will otherwise be English in front of a Spanish-speaking user.
Privacy manifest. Since May 2024 Apple requires every iOS app to ship a PrivacyInfo.xcprivacy declaring sensitive API usage. The Veridia SDK is pure Dart and does not ship its own framework, so the manifest belongs in your app, not in the SDK — this is your responsibility to author and keep current, and Veridia does not supply one. See Apple's privacy manifest documentation.
Two things that only bite at upload time
Neither affects the build, and both are easier to fix now than during a release.
ITSAppUsesNonExemptEncryption. If it is absent, App Store Connect asks an export-compliance question on every upload, including TestFlight. Veridia is reached over HTTPS only, which is exempt:
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
iPad + a single orientation is rejected. If your target declares iPad support (TARGETED_DEVICE_FAMILY = "1,2", Flutter's default) while UISupportedInterfaceOrientations~ipad allows only portrait, the upload fails validation with ITMS-90474: iPad multitasking requires all four orientations. Either support the four on iPad, or drop iPad from the device family. Our example app is iPhone-only, deliberately — the capture UI frames a document and a face against a fixed portrait guide, and no iPad layout has been looked at.
NSMicrophoneUsageDescription. The SDK never records audio — every CameraController it builds passes enableAudio: false. But the camera plugin links Apple's audio API regardless, and Apple's upload scan reads the binary rather than the call graph, so the notice can appear on an app that never opens the microphone. Declaring the key costs nothing: no dialog is ever shown, because nothing asks.
What runs on the device
- Camera permission and capture
- Downscale to at most 1600×1200 (never upscale)
- JPEG encode at quality 85
- Sharpness, from three independent operators — Laplacian variance, Tenengrad (Sobel) and Brenner — passed by vote rather than by any single number
- Mean-luminance brightness check
- On-device face detection for the selfie, via Google ML Kit
- The active-liveness capture, if you enabled it
- Upload of the images
The sharpness thresholds differ by surface, which is why a selfie that would fail as a document still passes. A document must clear two of the three operators; a selfie only one, and at much lower levels. Faces are legitimately smoother than printed text, and holding a phone at arm's length is not the same as photographing a card on a table — one threshold for both rejects real users.
For the document the operators are also run again over the document region rather than the whole frame. Averaging across a photo that is mostly desk dilutes a perfectly sharp card until it fails.
Everything else — OCR, MRZ parsing, face matching, glare and moiré detection, name fuzzy matching, confidence scoring and the verdict — runs server-side. The SDK makes no anti-spoofing claim of its own; the backend is the source of truth for approved / review / rejected.
Uploads
The images do not go to presigned object storage. Each upload slot returned by init points at a Veridia endpoint authenticated by X-Veridia-Upload-Token, a short-lived per-verification credential carried in the slot's own headers. The SDK forwards those headers verbatim, which is what makes the upload succeed.
This matters for you only if you are writing egress firewall rules: the bytes go to api.xxuxe.online, not to a storage host. You may still see "presigned R2 upload" in the package's own README or flow diagram — that wording is stale; the code is correct.
Flow
idle
↓ user presses Start
requestingCamera permission + rear camera
↓
captureDocFront ↔ reviewDocFront retake / confirm
↓ (if requireDocBack)
captureDocBack ↔ reviewDocBack retake / confirm
↓ (swap to front camera)
captureSelfie ↔ reviewSelfie retake / confirm
↓ (only if activeLiveness: true)
challenge anchor, then server-issued poses
↓
uploading PUT each image
↓
submitting POST /v1/verify/submit
↓
done ✓ onComplete fires
Error from anywhere → error state + onError
Webhook verification
The package exports a WebhookVerifier, but a webhook is delivered to a server, not to a phone: an app has no stable URL and cannot hold the signing secret. Verify webhooks in your backend with the JavaScript, Python or PHP SDK, or with the documented HMAC scheme in whatever language it runs.
What's next
- Webhooks — how the verdict actually reaches you
- Widget — the browser equivalent of this SDK
- JavaScript SDK · Python SDK · PHP SDK