usesense_flutter 2.4.9
usesense_flutter: ^2.4.9 copied to clipboard
Flutter plugin for UseSense human presence verification. Wraps native iOS and Android SDKs for DeepSense (device integrity), LiveSense (proof-of-life), and MatchSense (identity collision detection).
usesense_flutter #
Flutter plugin for Sense human presence verification. Wraps native iOS and Android SDKs via Pigeon type-safe platform channels.
Sense verifies human presence through three pillars:
- DeepSense -- Device and channel integrity (attestation, runtime integrity, capture pipeline analysis)
- LiveSense -- Multimodal liveness detection (facial dynamics, visual integrity, presentation attack detection, audio authenticity)
- MatchSense -- Face matching and deduplication (1:N face search, 1:1 verification, cross-identity risk scoring)
SenSei is the adaptive AI orchestration layer that coordinates these pillars and returns a decision.
Requirements #
| Requirement | Minimum Version |
|---|---|
| Flutter | 3.16+ |
| Dart | 3.2+ |
| iOS | 16.0+ |
| Xcode | 15.0+ |
| Android | API 28+ (Android 9.0; native SDK requires API 28) |
| Hardware | Front-facing camera |
Installation #
Add usesense_flutter to your pubspec.yaml:
dependencies:
usesense_flutter: ^2.4.4
Then run:
flutter pub get
iOS Setup #
The iOS SDK is distributed via CocoaPods. The plugin's podspec declares the dependency automatically. Ensure your ios/Podfile targets iOS 16.0+ (the default Flutter use_frameworks! is fine):
source 'https://cdn.cocoapods.org/'
platform :ios, '16.0'
On-device face mesh: face capture only records frames when face mesh detects a face, so MediaPipe must be linked or the liveness step fails with "No frames captured." As of UseSenseSDK 4.4.0 (pinned by this plugin) MediaPipe is vendored and pulled in automatically — you do not add
MediaPipeTasksVision, apre_installInfo.plist patch hook, or any linkage change. UseSenseSDK 4.4 is astatic_framework, so it works under the defaultuse_frameworks!.
Add camera and microphone usage descriptions to ios/Runner/Info.plist:
<key>NSCameraUsageDescription</key>
<string>Sense requires camera access for identity verification.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Sense requires microphone access for audio-based liveness challenges.</string>
Then install pods:
cd ios && pod install
Android Setup #
The native Android SDK is published to Maven Central at ai.usesense:sdk.
mavenCentral() is already in every Android project's
android/build.gradle.kts by default, so no custom repository
declaration is required. The Flutter plugin pulls the native dep
transitively when you run flutter pub get.
Minimum SDK — required. The native
ai.usesense:sdkrequiresminSdk 28(Android 9.0). Set it in your app'sandroid/app/build.gradle(orbuild.gradle.kts); a lower value fails the Android release build at manifest merge withuses-sdk:minSdkVersion … cannot be smaller than version 28 declared in library [ai.usesense:sdk]:android { defaultConfig { minSdkVersion 28 } }
If for some reason mavenCentral() has been removed from your
android/build.gradle.kts, add it back:
allprojects {
repositories {
google()
mavenCentral()
}
}
Add the required permissions to android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
Quick Start #
import 'package:usesense_flutter/usesense_flutter.dart';
final useSense = UseSenseFlutter();
// 1. Initialize once at app startup
await useSense.initialize(
UseSenseConfig(apiKey: 'pk_live_your_api_key'),
);
// 2. Subscribe to events before starting a session
useSense.onEvent.listen((event) {
print('${event.type}: ${event.data}');
});
// 3. Run an enrollment session
try {
final result = await useSense.startVerification(
VerificationRequest(sessionType: SessionType.enrollment),
);
if (result.isApproved) {
print('Verified. Identity: ${result.identityId}');
}
} on UseSenseError catch (e) {
print('Error ${e.code}: ${e.message}');
}
// 4. Clean up when done
useSense.dispose();
White-labeling (Appearance & Copy) #
The verification flow UI is fully customizable through two optional inputs you pass
on a flow run: appearance (FlowAppearance) and copy (FlowCopy). Values are
merged SDK-init > dashboard (org settings) > built-in default. Every field is
optional; anything you omit keeps the Sense default.
You can set this two ways: in code (below), or no-code via the dashboard's Flows → Appearance tab (saved on your org, delivered to every SDK and the hosted pages, no redeploy).
Customizable surfaces: colors (plus dark-mode overrides), typography, shape and button style, logo, background, icons/illustrations, the loader, and every subject-facing string plus privacy copy.
await UseSenseFlows.runFlow(
flowRunId: flowRunId,
sdkToken: sdkToken,
appearance: FlowAppearance(
colors: AppearanceColors(primary: '#E4002B'),
shape: AppearanceShape(buttonStyle: 'outline'),
),
copy: FlowCopy(
result: ResultCopy(successTitle: "You're verified"),
),
);
Full reference: docs/WHITE_LABEL.md in the web SDK.
Configuration Reference #
Pass a UseSenseConfig to initialize(). Only apiKey is required; all other fields have sensible defaults.
await useSense.initialize(
UseSenseConfig(
apiKey: 'pk_live_your_api_key',
environment: UseSenseEnvironment.production,
branding: BrandingConfig(
displayName: 'Acme Corp',
logoUrl: 'https://acme.com/logo.png',
primaryColor: '#4F46E5',
buttonRadius: 8,
),
googleCloudProjectNumber: 123456789, // Android Play Integrity
),
);
UseSenseConfig #
| Property | Type | Default | Description |
|---|---|---|---|
apiKey |
String |
required | Your Sense API key. Production keys start with pk_prod_* / sk_prod_*; sandbox keys start with pk_sandbox_* / sk_sandbox_* / dk_*. |
environment |
UseSenseEnvironment |
.auto |
Backend environment. .auto detects from the key prefix. |
baseUrl |
String? |
null |
Override the default backend URL. Useful for on-premise deployments and staging. |
branding |
BrandingConfig? |
null |
UI customization for the verification screen. |
googleCloudProjectNumber |
int? |
null |
Google Cloud project number for Android Play Integrity attestation. |
BrandingConfig #
| Property | Type | Default | Description |
|---|---|---|---|
displayName |
String? |
null |
Organization name shown in the verification UI. |
logoUrl |
String? |
null |
URL to your logo image. |
primaryColor |
String? |
null |
Hex color string, e.g. "#4F46E5". |
redirectUrl |
String? |
null |
URL to redirect after verification. |
buttonRadius |
int |
12 |
Corner radius for UI buttons. |
fontFamily |
String? |
null |
Custom font family name. |
UseSenseEnvironment #
| Value | Description |
|---|---|
sandbox |
Sandbox environment for development and testing. |
production |
Production environment for live users. |
auto |
Auto-detect from API key prefix (pk_* = production, sk_*/dk_* = sandbox). |
Session Types #
Sense supports two session types: enrollment (registering a new identity) and authentication (verifying a returning user).
Enrollment #
Creates a new identity. Use this the first time a user goes through verification.
final result = await useSense.startVerification(
VerificationRequest(
sessionType: SessionType.enrollment,
externalUserId: 'user_12345', // your internal user ID
metadata: {'tier': 'premium'}, // optional metadata
),
);
// Store the identityId for future authentication sessions
final identityId = result.identityId;
Authentication #
Verifies a returning user against a previously enrolled identity. The identityId from the enrollment result is required.
final result = await useSense.startVerification(
VerificationRequest(
sessionType: SessionType.authentication,
identityId: 'idt_abc123', // from a prior enrollment result
),
);
Remote Sessions #
For server-initiated flows, use the remote methods with a pre-created session or enrollment ID from your backend:
// Remote enrollment
final result = await useSense.startRemoteEnrollment('enr_xyz789');
// Remote verification
final result = await useSense.startRemoteVerification('ses_abc456');
VerificationRequest #
| Property | Type | Default | Description |
|---|---|---|---|
sessionType |
SessionType |
required | .enrollment or .authentication. |
externalUserId |
String? |
null |
Your internal user ID. Ties the session to a user in your system. |
identityId |
String? |
null |
Required for authentication. The identity ID returned from a prior enrollment. |
metadata |
Map<String, String>? |
null |
Arbitrary key-value pairs attached to the session. |
Handling Results #
startVerification, startRemoteEnrollment, and startRemoteVerification all return a UseSenseResult. The result contains a decision string and convenience getters.
IMPORTANT: The SDK result is for UI feedback only. Scores are intentionally redacted from the client SDK for security. You must never use the SDK-side decision for access-control or trust decisions. The definitive verdict arrives at your backend via a signed webhook. See Server-Side Webhook Verification.
UseSenseResult #
| Property | Type | Description |
|---|---|---|
sessionId |
String |
Unique session identifier. |
sessionType |
String? |
"enrollment" or "authentication". |
identityId |
String? |
The identity ID (assigned on enrollment, verified on authentication). |
decision |
String |
"APPROVE", "REJECT", or "MANUAL_REVIEW". |
timestamp |
String |
ISO 8601 timestamp of the decision. |
isApproved |
bool |
true when decision == "APPROVE". |
isRejected |
bool |
true when decision == "REJECT". |
isPendingReview |
bool |
true when decision == "MANUAL_REVIEW". |
Decision Handling Example #
final result = await useSense.startVerification(
VerificationRequest(sessionType: SessionType.enrollment),
);
if (result.isApproved) {
// Show success UI; wait for webhook confirmation on your backend
// before granting access
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => VerificationPendingScreen(
sessionId: result.sessionId,
)),
);
} else if (result.isRejected) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Verification unsuccessful. Please try again.')),
);
} else if (result.isPendingReview) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => ManualReviewScreen(
sessionId: result.sessionId,
)),
);
}
Event Listening #
The onEvent stream emits UseSenseEvent objects throughout a verification session. The onCancelled stream emits when the user dismisses the verification UI.
Using StreamBuilder (Declarative) #
StreamBuilder<UseSenseEvent>(
stream: useSense.onEvent,
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
final event = snapshot.data!;
switch (event.type) {
case UseSenseEventType.captureStarted:
return const Text('Hold still...');
case UseSenseEventType.uploadProgress:
final progress = event.data?['progress'] as double? ?? 0;
return LinearProgressIndicator(value: progress);
case UseSenseEventType.decisionReceived:
return const Text('Complete');
default:
return const SizedBox.shrink();
}
},
)
Imperative Subscription #
final subscription = useSense.onEvent.listen((event) {
switch (event.type) {
case UseSenseEventType.sessionCreated:
log('Session created: ${event.data?['sessionId']}');
case UseSenseEventType.challengeStarted:
log('Challenge: ${event.data?['challengeType']}');
case UseSenseEventType.uploadProgress:
log('Upload: ${event.data?['progress']}');
case UseSenseEventType.error:
log('Error during session: ${event.data}');
default:
break;
}
});
// Listen for user cancellation
final cancelSub = useSense.onCancelled.listen((_) {
log('User cancelled verification');
});
// Clean up when done
subscription.cancel();
cancelSub.cancel();
Event Types #
| Event | Description | Data Fields |
|---|---|---|
sessionCreated |
Session initialized on the server. | sessionId |
permissionsRequested |
Camera/microphone permissions requested. | |
permissionsGranted |
Permissions granted by user. | |
permissionsDenied |
Permissions denied by user. | |
captureStarted |
Camera capture has begun. | |
frameCaptured |
A video frame was captured. | |
captureCompleted |
Capture phase finished. | |
audioRecordStarted |
Audio recording began (speak_phrase challenge). | |
audioRecordCompleted |
Audio recording finished. | |
challengeStarted |
A liveness challenge started. | challengeType |
challengeCompleted |
A liveness challenge completed. | challengeType |
uploadStarted |
Data upload to server began. | |
uploadProgress |
Upload progress update. | progress (0.0-1.0) |
uploadCompleted |
Upload finished. | |
completeStarted |
Server-side analysis started. | |
decisionReceived |
Decision returned from server. | decision |
imageQualityCheck |
Image quality assessment result. | |
error |
An error occurred during the session. | code, message |
Error Handling #
All verification methods throw UseSenseError on failure. Wrap calls in a try/on block.
try {
final result = await useSense.startVerification(
VerificationRequest(sessionType: SessionType.enrollment),
);
// handle result
} on UseSenseError catch (e) {
if (e.isRetryable) {
// Network errors, upload failures -- safe to retry
showRetryDialog(e.message);
} else {
switch (e.code) {
case UseSenseError.cameraPermissionDenied:
openAppSettings();
case UseSenseError.sessionExpired:
// Sessions expire after 15 minutes; start a new one
showExpiredMessage();
case UseSenseError.quotaExceeded:
contactSupport();
case UseSenseError.sdkNotInitialized:
await useSense.initialize(config);
case UseSenseError.sessionCancelled:
// User dismissed the UI; no action needed
break;
default:
showGenericError(e.message);
}
}
}
Error Code Reference #
| Code | Constant | Retryable | Description |
|---|---|---|---|
| 1001 | cameraUnavailable |
No | Camera hardware not available on this device. |
| 1002 | cameraPermissionDenied |
No | User denied camera permission. |
| 1003 | microphonePermissionDenied |
No | User denied microphone permission. |
| 2001 | networkError |
Yes | Network communication failure. |
| 2002 | networkTimeout |
Yes | Network request timed out. |
| 3001 | sessionExpired |
No | Session expired (15-minute limit). Start a new session. |
| 3002 | uploadFailed |
Yes | Data upload to server failed. |
| 4001 | captureFailed |
No | Camera frame capture failed. |
| 4002 | encodingFailed |
No | Frame encoding failed. |
| 5001 | invalidConfig |
No | Invalid SDK configuration (e.g. missing or malformed API key). |
| 6001 | quotaExceeded |
No | Organization verification quota exceeded. |
| 7001 | sdkNotInitialized |
No | initialize() was not called before a verification method. |
| 8001 | sessionCancelled |
No | User cancelled the verification session. |
UseSenseError Properties #
| Property | Type | Description |
|---|---|---|
code |
int |
Numeric error code from the table above. |
message |
String |
Human-readable error description. |
serverCode |
String? |
Server-specific error code, if available. |
isRetryable |
bool |
Whether the operation can be retried. |
details |
Map<String, String>? |
Additional context about the error. |
Server-Side Webhook Verification #
CRITICAL: The SDK result returned to the client is for UI feedback only. Verification scores are intentionally excluded from the client response. Never grant or deny access based on the SDK-side
decisionalone. The authoritative verdict is delivered to your backend via an HMAC-SHA256 signed webhook.
How It Works #
- The SDK returns a
UseSenseResultto your Flutter app with a preliminarydecision. - Your app shows appropriate UI feedback (success, rejection, or pending review).
- Sense sends a webhook
POSTrequest to your configured endpoint with the full session result, including scores and risk signals. - Your backend verifies the webhook signature and applies the final access-control decision.
Webhook Payload Structure #
{
"event": "session.completed",
"session_id": "ses_abc123",
"session_type": "enrollment",
"identity_id": "idt_xyz789",
"decision": "APPROVE",
"timestamp": "2025-01-15T10:30:00Z",
"scores": {
"deep_sense": 0.95,
"live_sense": 0.98,
"match_sense": 0.92
},
"risk_signals": [],
"metadata": {
"tier": "premium"
}
}
Signature Verification #
Every webhook includes an X-UseSense-Signature header containing an HMAC-SHA256 signature computed over the raw request body using your webhook secret.
Node.js
const crypto = require('crypto');
function verifyWebhook(requestBody, signatureHeader, webhookSecret) {
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(requestBody, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected),
);
}
// Express middleware
app.post('/webhooks/usesense', express.raw({ type: '*/*' }), (req, res) => {
const signature = req.headers['x-usesense-signature'];
if (!verifyWebhook(req.body, signature, process.env.USESENSE_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(req.body);
const { session_id, decision } = payload;
// Apply your access-control logic here
if (decision === 'APPROVE') {
// Grant access
}
res.status(200).send('OK');
});
Python
import hmac
import hashlib
def verify_webhook(request_body: bytes, signature: str, webhook_secret: str) -> bool:
expected = hmac.new(
webhook_secret.encode('utf-8'),
request_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)
# Flask example
@app.route('/webhooks/usesense', methods=['POST'])
def handle_usesense_webhook():
signature = request.headers.get('X-UseSense-Signature')
if not verify_webhook(request.data, signature, os.environ['USESENSE_WEBHOOK_SECRET']):
abort(401)
payload = request.get_json()
session_id = payload['session_id']
decision = payload['decision']
# Apply your access-control logic here
if decision == 'APPROVE':
# Grant access
pass
return 'OK', 200
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
func verifyWebhook(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
signature := r.Header.Get("X-UseSense-Signature")
secret := os.Getenv("USESENSE_WEBHOOK_SECRET")
if !verifyWebhook(body, signature, secret) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Parse body and apply access-control logic
w.WriteHeader(http.StatusOK)
}
Sandbox vs Production #
| Sandbox | Production | |
|---|---|---|
| API key prefix | sk_* or dk_* |
pk_* |
| Environment | UseSenseEnvironment.sandbox |
UseSenseEnvironment.production |
| Real verification | No (simulated responses) | Yes |
| Billing | Not charged | Counted against quota |
| Use case | Development and testing | Live end users |
When environment is set to .auto (the default), the SDK detects sandbox vs. production from the API key prefix.
// Sandbox -- for development
await useSense.initialize(
UseSenseConfig(apiKey: 'sk_test_your_sandbox_key'),
);
// Production -- for live users
await useSense.initialize(
UseSenseConfig(apiKey: 'pk_live_your_production_key'),
);
Troubleshooting #
sdkNotInitialized error (code 7001) #
initialize() must be called and awaited before calling any verification method. Verify that initialization completed without error.
Camera permission denied on Android #
Ensure <uses-permission android:name="android.permission.CAMERA" /> is present in your AndroidManifest.xml. On Android 6.0+, runtime permission is requested automatically by the native SDK.
Camera permission denied on iOS #
Add NSCameraUsageDescription to your Info.plist with a non-empty description string. If the user previously denied permission, direct them to the system Settings app.
iOS build fails with "Module 'UseSenseSDK' not found" #
Run cd ios && pod install in your Flutter project. If the issue persists, try pod repo update and then pod install again.
iOS face step fails with "No frames captured" #
The face liveness step records frames only when on-device face mesh (MediaPipe) is linked. MediaPipe is bundled with UseSenseSDK >= 4.4.0, so this almost always means the native SDK is below 4.4.0. Fix: ensure the plugin (and therefore UseSenseSDK >= 4.4.0) is up to date — flutter pub upgrade, then cd ios && pod install (try pod repo update first if it can't resolve UseSenseMediaPipe).
Events not received #
Subscribe to onEvent before calling startVerification(). The stream is a broadcast stream -- late subscribers will miss events emitted before subscription.
Session expired (code 3001) #
Sessions expire after 15 minutes. If you receive this error, start a new verification session. Do not attempt to resume an expired session.
Upload failures (code 3002) #
Upload failures are retryable. Check network connectivity and retry the verification. Poor network conditions or very large payloads can cause transient upload failures.
Play Integrity errors on Android #
If you are using Android Play Integrity for device attestation, ensure googleCloudProjectNumber is set correctly in UseSenseConfig and that the Play Integrity API is enabled in your Google Cloud Console.
Pigeon Code Generation #
Platform channel communication uses Pigeon for type-safe, compile-time-checked interfaces. Generated files are checked into source control. If you modify the Pigeon schema, regenerate with:
cd usesense_flutter
dart run pigeon --input pigeons/usesense_api.dart
This generates:
lib/src/generated/usesense_api.g.dart(Dart)android/src/main/kotlin/com/usesense/flutter/UseSenseApi.g.kt(Kotlin)ios/Classes/UseSenseApi.g.swift(Swift)
Do not edit generated files manually.
Changelog #
See CHANGELOG.md.
License #
MIT License. See LICENSE for details.
Support #
- Documentation: watchtower.usesense.ai/developer-docs
- Dashboard: watchtower.usesense.ai
- Email: support@usesense.ai
- Repository: github.com/qudusadeyemi/flutter-usesense