Skip to main content

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+.

It is not on pub.dev

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
The Flutter floor is 3.38.4, and it is hard

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

FieldRequiredDefaultNotes
publishableKeyyesqv_pub_... or qv_pubt_...
apiBasenohttps://api.xxuxe.onlineOverride only for a regional deployment
userRefnoYour user id. Echoed back in the webhook
countrynoISO 3166-1 alpha-2 (GT, MX, BR, …)
documentTypenoIf null, the SDK asks the user
submittedFullNamenoFuzzy-matched against the document server-side
requireDocBacknofollows documentTypeSee below
localenoVeridiaLocale.enen / es / pt
activeLivenessnofalseRuns 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.

What it does not prove

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.passportfalse. Passports are single-page.
  • Any other documentTypetrue.
  • documentType null → 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.

You cannot read the verdict from the app

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 enumwireValueWhen
cameraDeniedcamera_deniedUser refused the camera permission
cameraUnavailablecamera_unavailableNo usable camera on the device
noFaceInSelfieno_face_in_selfieOn-device face detection found nothing
blurryImageblurry_imageSharpness check failed
uploadFailedupload_failedA PUT did not succeed
apiUnreachableapi_unreachableNetwork failure reaching Veridia
invalidApiKeyinvalid_api_keyKey missing, malformed or revoked
insufficientCreditsinsufficient_creditsTenant balance exhausted (HTTP 402)
rateLimitedrate_limitedHTTP 429
userCancelleduser_cancelledUser backed out of the flow
internalErrorinternal_errorAnything 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 SettingsiOS Deployment Target15.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.

iOS is documented, not yet verified on a device

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