deepidsdk_flutter
Flutter plugin that bridges the DeepID SDK on Android and iOS.
Provides device enrollment (SIM-based fingerprinting) and an interactive SIM
binding flow — a two-step fraud-prevention mechanism that cryptographically
links a user's phone number to their device.
Note — SDK binaries are not bundled in this package.
The native Android AAR and iOS xcframework are distributed separately by DeepID. You will receive them via email or by some other means. Place them at the exact paths shown in Prerequisites before runningflutter pub getor building your app.
Table of contents
- Prerequisites
- Requirements
- Installation
- Android setup
- iOS setup
- Integration walkthrough
- API reference
- Error handling
- Platform differences
- Troubleshooting
Prerequisites
Before adding this plugin to your app you must obtain the native SDK binaries from DeepID (contact deepidsdk.com if you haven't received them yet) and place them inside the plugin's directory in your pub cache.
The pub cache path for this plugin is:
~/.pub-cache/hosted/pub.flutter-io.cn/deepidsdk_flutter-<version>/
Replace <version> with the version you added to your pubspec.yaml
(e.g. deepidsdk_flutter-2.3.0).
The binaries are paired with the plugin version — upgrade them together. Each release is built against a specific AAR and xcframework. Pairing a newer plugin with an older
deepidsdk.aarfails the Android build with an unresolved-reference error naming a DeepID class; pairing it with an olderDeepIdSDK.xcframeworkstill compiles, but that release's iOS fixes are silently absent. Whenever you bumpdeepidsdk_flutter, replace both binaries with the ones DeepID ships for that version.
Android
~/.pub-cache/hosted/pub.flutter-io.cn/deepidsdk_flutter-<version>/
└── android/
└── libs/
└── deepidsdk.aar ← place the AAR here
The Gradle build script auto-publishes this AAR into a local Maven repository at configuration time. If the file is missing the build will fail immediately with a descriptive error message pointing to this path.
iOS
~/.pub-cache/hosted/pub.flutter-io.cn/deepidsdk_flutter-<version>/
└── ios/
└── Frameworks/
└── DeepIdSDK.xcframework/ ← place the xcframework here
├── Info.plist
├── ios-arm64/
└── ios-arm64_x86_64-simulator/
CocoaPods picks up the xcframework via the plugin's podspec. If the directory is
absent, pod install fails immediately with a message naming the exact path it
looked in — the same way the Android build fails for a missing AAR, rather than
surfacing later as an unexplained missing-module error at compile time.
Verify before continuing
After placing both files, your pub cache directory should look like this:
~/.pub-cache/hosted/pub.flutter-io.cn/deepidsdk_flutter-<version>/
├── android/
│ └── libs/
│ └── deepidsdk.aar ✓
├── ios/
│ └── Frameworks/
│ └── DeepIdSDK.xcframework/ ✓
├── lib/
└── pubspec.yaml
Only once both binaries are in place should you proceed with the steps below.
Requirements
| Platform | Minimum version |
|---|---|
| Android | API 29 (Android 10) |
| iOS | 15.0 |
| Flutter | 3.10.0 |
| Dart SDK | ≥ 3.0.0 |
Installation
Step 1 — Place the SDK binaries as described in Prerequisites.
Step 2 — Add the plugin to your pubspec.yaml:
dependencies:
deepidsdk_flutter: ^2.3.0
Step 3 — If your SIM binding flow requires runtime permissions on Android
(it does — see below), also add permission_handler:
dependencies:
permission_handler: ^11.0.0
Step 4 — Fetch dependencies:
flutter pub get
Android setup
Permissions
deepidsdk.aar declares the permissions below in its own manifest, and the
Android manifest-merger merges them into your host app at build time. You do
not need to add any of them to your own AndroidManifest.xml, but you should
know they will appear in your final APK because some of them are protected
and may trigger Play Console reviews.
Standard (install-time, normal protection):
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
Dangerous (require runtime grant before startSimBinding()):
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
READ_SMS backs the UPI 'SMS sent check': after the silent send, the SDK
validates the verification SMS in the device's sent box (see
onSmsSentCheck on startSimBinding). It is in the same permission group as
SEND_SMS, so the single SMS consent dialog your app already shows covers
both — but it is one more SMS-group permission in your APK, so account for it
in your Play Console SMS declaration.
Protected / policy-restricted (merged silently — see notes below):
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
The SDK also declares one feature flag:
<uses-feature android:name="android.hardware.telephony" android:required="false" />
Requesting the dangerous permissions at runtime
Request READ_PHONE_STATE + SEND_SMS immediately before launching the SIM
binding flow:
import 'package:permission_handler/permission_handler.dart';
Future<bool> _requestSimBindingPermissions() async {
final statuses = await [Permission.phone, Permission.sms].request();
return statuses[Permission.phone]!.isGranted &&
statuses[Permission.sms]!.isGranted;
}
Without both grants the SDK cannot read the SIM list or send the silent
verification SMS — startSimBinding() will fail or return an error.
Permission.sms covers the whole SMS group, including the READ_SMS grant
the sent-box check needs; if that grant is somehow missing, the binding still
proceeds and onSmsSentCheck reports checked: false with the reason.
build.gradle — minSdk
The bundled deepidsdk.aar declares minSdkVersion="29". Your app module's
android/app/build.gradle must set minSdk to at least 29 (Android 10) —
the Android manifest-merger will fail with
uses-sdk:minSdkVersion N cannot be smaller than version 29 declared in library for any lower value.
android {
defaultConfig {
minSdk 29
}
}
Activity (auto-registered)
The plugin registers SimBindingActivity (a transparent Compose host) in its
own manifest. It is never launched directly; the plugin starts it internally
when startSimBinding() is called. No changes to your manifest are needed.
iOS setup
CocoaPods
The plugin uses CocoaPods to link DeepIdSDK.xcframework. You must have placed
it under ios/Frameworks/ (see Prerequisites) before running
pod install.
cd ios && pod install
The podspec pulls in the required system frameworks automatically:
MessageUI and CoreTelephony.
Minimum iOS target: Your app target must be set to iOS 15.0 or later — the
DeepIdSDK.framework is built with MinimumOSVersion = 15.0, so pod install
will fail on lower targets.
In Xcode: Target → General → Minimum Deployments → iOS 15.0, or in
ios/Podfile:
platform :ios, '15.0'
Info.plist — usage descriptions
DeepIdSDK.framework links against LocalAuthentication, AVFoundation,
CoreLocation, and CoreMotion (verified via otool -L). Even when the SDK
calls only metadata/availability APIs on these frameworks and triggers no
runtime permission prompt, App Review's static analyzer will reject a binary
that imports them without the corresponding NS*UsageDescription keys.
Add the following four keys to your app's ios/Runner/Info.plist:
<!-- Biometric hardware probe — LAContext.canEvaluatePolicy(.biometrics) -->
<key>NSFaceIDUsageDescription</key>
<string>DeepID Protect checks for biometric hardware availability during enrollment.</string>
<!-- Camera-hardware enumeration — AVCaptureDevice.default(...) -->
<key>NSCameraUsageDescription</key>
<string>DeepID Protect queries camera hardware capabilities to build a device fingerprint during enrollment. No image or video is captured.</string>
<!-- Location-services availability — CLLocationManager.locationServicesEnabled() -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>DeepID Protect checks location-service availability as a device-integrity signal during enrollment.</string>
<!-- Motion-sensor availability — CMMotionManager().isAccelerometerAvailable etc. -->
<key>NSMotionUsageDescription</key>
<string>DeepID Protect inspects motion-sensor availability as a device-integrity signal during enrollment. No sensor data is recorded.</string>
No runtime permission prompts appear during enrollment or SIM binding — these
are all metadata/availability calls. NSContactsUsageDescription,
NSPhotoLibraryUsageDescription, NSMicrophoneUsageDescription, and
NSUserTrackingUsageDescription are not needed and should not be added
(the SDK does not link Contacts, Photos, AdSupport, or AppTrackingTransparency).
PrivacyInfo.xcprivacy
DeepIdSDK.framework ships with its own PrivacyInfo.xcprivacy declaring
the required-reason APIs it uses (SystemBootTime, UserDefaults,
DiskSpace, FileTimestamp) and the data categories it collects
(DeviceID, PhoneNumber, linked, non-tracking). No action is required in
your app — your app's own privacy manifest (if any) is separate and lives at
ios/Runner/PrivacyInfo.xcprivacy.
Integration walkthrough
1. Initialize the SDK (once, at app startup)
Call DeepId.initialize() as early as possible — ideally in your root widget's
initState. The call returns immediately after constructing the SDK; enrollment
runs in the background.
import 'package:deepidsdk_flutter/deepidsdk_flutter.dart';
@override
void initState() {
super.initState();
_initDeepId();
}
Future<void> _initDeepId() async {
try {
await DeepId.initialize(
appKey: 'YOUR_APP_KEY',
appSecret: 'YOUR_APP_SECRET',
onEnrollment: (EnrollmentResult result) {
// Fires once when deepId + sessionId are ready. Fresh enrollment
// can also include device intelligence.
// Only now is it safe to show the SIM binding CTA.
setState(() {
_deepId = result.deepId;
_sessionId = result.sessionId;
_deviceIntelligence = result.deviceIntelligence;
_enrollmentReady = true;
});
},
onEnrollmentError: (DeepIdException error) {
// Enrollment failed — show an error state.
setState(() => _enrollmentError = error.message);
},
enrollmentTimeout: const Duration(seconds: 30),
);
} on DeepIdException catch (e) {
// initialize() itself failed — bad credentials or SDK construction error.
setState(() => _enrollmentError = e.message);
}
}
initialize() throws DeepIdException only if the credentials are missing or
the SDK cannot be constructed. Network/server errors during the background
enrollment are delivered via onEnrollmentError, not as exceptions from
initialize().
2. Gate the SIM binding CTA on enrollment
The sim binding cannot start without the deepid enrollment, so first initialize the DeepIdSDK which enrolls the DeepId automatically and then proceed with the sim binding.
3. Request Android permissions (Android only)
Request READ_PHONE_STATE + the SMS group immediately before launching the
flow, not at app startup — the system associates the rationale dialog with the
action that requires the permission. Permission.sms grants the whole group:
SEND_SMS for the silent verification SMS and READ_SMS for the sent-box
check, behind one dialog:
Future<void> _startSimBinding() async {
if (Platform.isAndroid) {
final statuses = await [Permission.phone, Permission.sms].request();
final granted = statuses[Permission.phone]!.isGranted &&
statuses[Permission.sms]!.isGranted;
if (!granted) {
// Show permission rationale to the user and return.
return;
}
}
try {
final result = await DeepId.startSimBinding(
phoneNumber: '+911234567890', // optional
);
print('Bound: ${result.deepId} / ${result.mobile}');
} on DeepIdException catch (e) {
if (e.code == DeepIdErrorCode.userCancelled) {
// User dismissed the sheet — treat as a no-op, not an error.
} else {
// Real failure — show error message.
print('SIM binding failed: ${e.message}');
}
}
}
4. Phone number parameter
startSimBinding({String? phoneNumber}) accepts an optional E.164-formatted
number:
- Provided: Pre-fills the number field in the native sheet. The user still confirms before verification is sent.
- Omitted: The native UI displays all available SIMs; the user selects one and enters their number.
API reference
DeepId class
All methods are static. The class cannot be instantiated.
DeepId.initialize()
static Future<void> initialize({
required String appKey,
required String appSecret,
void Function(EnrollmentResult)? onEnrollment,
void Function(DeepIdException)? onEnrollmentError,
Duration enrollmentTimeout = const Duration(seconds: 150),
})
Constructs and starts the native SDK. Returns as soon as the SDK object is created — does not block until enrollment finishes.
| Parameter | Description |
|---|---|
appKey |
Your DeepID app key. Must not be empty. |
appSecret |
Your DeepID app secret. Must not be empty. |
onEnrollment |
Fires once when the server returns both deepId and sessionId; fresh enrollment can also include deviceIntelligence. |
onEnrollmentError |
Fires if enrollment times out or fails. Optional. |
enrollmentTimeout |
How long to wait for enrollment. Defaults to 150 s. |
Throws DeepIdException if credentials are invalid or SDK construction fails.
If you call initialize() a second time (e.g., hot restart), the existing SDK
instance is reused — no duplicate initialization occurs.
DeepId.onEnrollment()
static void onEnrollment({
required void Function(EnrollmentResult result) onSuccess,
void Function(DeepIdException error)? onError,
Duration timeout = const Duration(seconds: 30),
})
Registers an enrollment callback independently of initialize(). Useful when
you need to attach a callback in a different part of the widget tree than where
you called initialize().
- If enrollment has already completed,
onSuccessfires on the next event-loop tick. - If enrollment has not completed,
onSuccessfires the moment the native SDK delivers the result. - If
timeoutexpires before enrollment completes,onErrorreceivesDeepIdErrorCode.enrollmentTimeout.
DeepId.getFreshDeviceIntelligence()
static Future<Map<String, dynamic>?> getFreshDeviceIntelligence()
Collects a fresh device intelligence object from the native SDK.
Returns a Map<String, dynamic> when fresh data is available, or null when
the native SDK is initialized but fresh data is unavailable. Throws
DeepIdException if initialize() has not been called or the native request
fails.
final deviceIntelligence = await DeepId.getFreshDeviceIntelligence();
print(deviceIntelligence);
DeepId.verifySecurityPolicy()
static Future<AttestationResult> verifySecurityPolicy({
required String action,
String? reference,
Map<String, String>? metadata,
Duration timeout = const Duration(seconds: 8),
bool deferEnforcement = false,
})
Re-measures device posture now, against a challenge the server issued, and returns a verdict plus a reference your backend redeems.
Enrollment establishes posture once, at app start. Everything after that trusts a measurement that may be days old — and the attack this closes is specific: the user logs in on a clean device, passes every check, and then the attacker attaches a hooking framework. From that point the process is theirs.
⚠️ What this returns is advisory. It is not the security boundary.
The device belongs to the attacker. A branch that reads
Allowedand proceeds is a branch an attacker patches to readAllowedunconditionally, with the same tool this feature exists to detect.what this returns → drives UX. Show a message, disable the button. what your backend checks → drives authorisation. The payment happens or it does not.Send
Allowed.attestationIdto your backend, and have your backend redeem it against DeepID before honouring the operation:POST https://deepid.surepass.app/api/biz/attestations/verify Authorization: Bearer dsk_live_… { "attestation_id": "…", "action": "payment.initiate", "reference": "<orderId>" }If that does not return
allow, the payment does not happen. An integration that acts only on the local return value has bought nothing.
| Parameter | Description |
|---|---|
action |
What is being attested, as domain.verb — payment.initiate, beneficiary.add. Must match ^[a-z0-9]+(\.[a-z0-9_]+)+$, 3–64 chars. Keep it stable per operation: it is the key the ledger groups by and the field the webhook carries. |
reference |
Your own id (order id, mandate id), echoed back on verification. Not unique-checked, not a secret. |
metadata |
At most 8 entries, keys and values at most 64 characters. |
timeout |
One attempt fits inside this. There is no silent retry. |
deferEnforcement |
Suppress the SDK's own policy action. See the enforcement note below. |
Never cached — two critical operations on one screen are two attestations.
switch (await DeepId.verifySecurityPolicy(
action: 'payment.initiate',
reference: orderId,
)) {
case Allowed(:final attestationId):
await myBackend.pay(orderId, attestation: attestationId);
case Blocked(:final displayMessage):
showError(displayMessage);
case Challenge(:final attestationId):
await startStepUp(attestationId);
case Unavailable():
showError('Could not verify device security. Try again.');
}
AttestationResult is sealed, so switch is exhaustive and Unavailable
cannot be omitted silently. It is a normal result rather than a thrown
exception, deliberately — an exception lands in the catch you wrote for
connectivity, which is how fail-open paths get written by accident.
Unavailable is not a pass. For any operation NPCI considers critical,
treat it exactly as Blocked.
| Cause | Meaning |
|---|---|
network |
The request never reached the server, or its reply never arrived. |
timeout |
The attempt exceeded timeout. |
notEnrolled |
Called before enrollment completed. A programming error — await onEnrollment first. |
rateLimited |
The server is rate limiting this device or session. |
internalError |
Anything else, including a verdict this build could not interpret. |
Enforcement differs by platform, and it is not symmetric. Android presents a blocking alert for
blockand a dismissible one forwarn. iOS has no policy-enforcement layer, so it reportspolicyActionand does nothing. Neither platform terminates the process forclose. If your response to a compromised device matters, implement it fromBlockedyourself.
DeepId.prewarmAttestation()
static Future<void> prewarmAttestation({required String action})
Fetches the attestation challenge ahead of time, so verifySecurityPolicy() at
button press costs one round trip instead of two. Call it on screen entry.
Only the challenge is pre-fetched. Posture is always measured at call time — that is the entire point of the endpoint. The challenge is valid for 120 s and bound to the session; if it has expired by submission the SDK fetches a new one transparently.
Best-effort: failures are silent, and an attestation that finds nothing cached simply fetches its own.
@override
void initState() {
super.initState();
DeepId.prewarmAttestation(action: 'payment.initiate');
}
DeepId.isAttestationSupported
static Future<bool> get isAttestationSupported
Whether the bundled native SDK supports attestation. The Android AAR and iOS xcframework are dropped in out-of-band and can be older than this plugin.
Requires deepidsdk 2.2.0+ on Android and DeepIdSDK.xcframework 2.2.0+
on iOS. When false, verifySecurityPolicy() returns Unavailable rather than
crashing — but checking at startup lets you keep the operation behind your own
control instead of discovering it at the payment button.
DeepId.startSimBinding()
static Future<SimBindingResult> startSimBinding({
String? phoneNumber,
void Function(SimBindingInitResponse response)? onSimBindingInit,
void Function(SmsSentCheckResult result)? onSmsSentCheck,
})
Presents the native SIM binding UI. Awaits user interaction and verification.
Returns a SimBindingResult on success. Throws DeepIdException on any
failure including user cancellation.
Preconditions:
initialize()must have been called.onEnrollmentmust have fired — i.e., bothdeepIdandsessionIdmust be available.- On Android:
READ_PHONE_STATEand the SMS group (SEND_SMS, andREAD_SMSfor the sent-box check) must be granted.
Observing /sim-binding/init mid-flow
The returned Future only completes when the whole flow ends. To see the
/api/sdk/sim-binding/init response as it happens — before the verification
SMS goes out — pass onSimBindingInit:
final result = await DeepId.startSimBinding(
phoneNumber: '+919876543210',
onSimBindingInit: (init) {
if (init.ok != true) {
print('Init rejected (${init.clientId}): ${init.error ?? init.message}');
return;
}
print('Attempt ${init.clientId} — SMS going to ${init.smsTargetMobile}');
},
);
It is called for every outcome, so there is only one branch to handle:
| Outcome | What you get |
|---|---|
| Server accepted | The response verbatim, ok == true |
| Server rejected | The response verbatim, ok == false, with error / message |
| Never reached the server (no session, transport error, non-2xx) | Synthesized ok == false, reason in error |
Delivered once per init attempt, not once per flow. A flow normally makes
exactly one, but both platforms can start a second — Android after a retry or
an invalidated token, iOS if the user double-taps the confirm button. Each
attempt has its own clientId and bindingHash; treat a repeat as superseding
the one before it.
The callback is dropped when the Future settles, so it cannot fire into a
later flow.
The UPI 'SMS sent check' (onSmsSentCheck, Android only)
After the platform confirms the verification SMS left the radio, the SDK reads
the device's sent SMS box and validates that a record addressed to the
verification number and carrying the verification content exists — the NPCI
requirement that device binding must not happen if the token was not sent from
the device the app is installed on. Pass onSmsSentCheck to observe the
verdict mid-flow:
final result = await DeepId.startSimBinding(
phoneNumber: '+919876543210',
onSmsSentCheck: (check) {
if (check.validated == true) return; // record found
print('Sent check ${check.checked == true ? "failed" : "skipped"}: '
'${check.reason}');
},
);
What the SDK does with each verdict:
| Verdict | Meaning | SDK behaviour |
|---|---|---|
checked: true, validated: true |
Matching record found in the sent box | Verification proceeds |
checked: true, validated: false |
Sent box readable, no matching record | Binding fails right after the callback |
checked: false |
Check could not run (READ_SMS missing, unreadable provider) |
Flow proceeds; reason says why — enforce a stricter policy yourself if you need one |
checked: false deliberately does not fail the binding: an unreadable sent box
is not evidence the SMS was not sent. The result never carries the token, so
it is safe to log whole.
iOS never delivers this callback — iOS offers no read access to the SMS
store. The equivalent assurance there is the Messages composer's own sent
result, which the SDK already requires before verification starts. Like
onSimBindingInit, the callback is dropped when the Future settles.
DeepId.logout()
static Future<bool> logout()
Logs out of the current enrolled session. Clears the cached deepId,
sessionId, and deviceIntelligence on the Dart side, then asks the native
SDK to drop its stored session, DeepId credentials, SIM binding state, and
any locally persisted identifier so the next initialize() call performs a
fresh enrollment (new deepId / sessionId, onEnrollment fires again).
Returns true if there was an active session that actually got logged out,
false if there was nothing to clear (e.g. called twice in a row, or before
initialize() ever completed) — so you can tell a real logout from a no-op
instead of assuming success.
Safe to call when no session is active — resolves successfully (with false)
as a no-op. A pending onEnrollment (if any) is rejected with the message
Enrollment cancelled by logout().
final loggedOut = await DeepId.logout();
print(loggedOut ? 'Session cleared.' : 'Nothing to log out of.');
// Later, re-initialize to enroll a fresh session.
await DeepId.initialize(
appKey: appKey,
appSecret: appSecret,
onEnrollment: (result) => print('Re-enrolled: ${result.deepId}'),
);
Throws DeepIdException if the native cleanup fails.
DeepId.isInitialized
static Future<bool> get isInitialized
Returns true if initialize() has been successfully called. Does not
indicate that enrollment is complete — use the onEnrollment callback for that.
DeepId.deepId / DeepId.sessionId / DeepId.deviceIntelligence
static String? get deepId
static String? get sessionId
static Map<String, dynamic>? get deviceIntelligence
Synchronous accessors for the last enrollment result. deepId and sessionId
return null until onEnrollment has fired at least once in this process.
deviceIntelligence is the latest device intelligence object seen by the plugin
from either enrollment or getFreshDeviceIntelligence(). It may be null on
cached-session paths until a fresh request succeeds.
EnrollmentResult
Delivered to the onEnrollment / onSuccess callback.
| Field | Type | Description |
|---|---|---|
deepId |
String |
Unique device fingerprint token. Non-empty. |
sessionId |
String |
Current enrollment session identifier. Non-empty. |
deviceIntelligence |
Map<String, dynamic>? |
Device intelligence object from fresh enrollment when available. May be null for cached/existing-session paths. |
SimBindingResult
Returned by a successful startSimBinding() call.
| Field | Type | Description |
|---|---|---|
success |
bool |
Always true when the object is returned (errors throw instead). |
deepId |
String |
Device token at the time of binding. |
sessionId |
String |
Session identifier at the time of binding. |
mobile |
String |
Verified phone number. See Platform differences. |
message |
String |
Human-readable confirmation string from the SDK. |
SimBindingInitResponse
Passed to the onSimBindingInit callback of startSimBinding(), mid-flow.
Every field is nullable — the server omits the token fields when it rejects an
init, and a failure that never reached the server arrives with only ok and
error set. A non-null bindingHash is the reliable signal that init
succeeded.
| Field | Type | Description |
|---|---|---|
ok |
bool? |
true when the server issued a binding token. |
clientId |
String? |
Backend identifier for this attempt, e.g. sim_binding_hRXbkIMrSsQpUmwciUpm. Safe to log. |
bindingHash |
String? |
⚠️ Correlation hash for this attempt. Live token. |
smsTargetMobile |
String? |
Gateway number the verification SMS is addressed to. |
smsContent |
String? |
⚠️ The verification SMS body. Live token. |
instructions |
String? |
Human-readable guidance from the server. |
error |
String? |
Failure reason when ok != true. |
message |
String? |
Server-supplied detail when ok != true. |
clientId is present whenever the server answered — including most rejections
— and is null only when the call never reached the server. It is the field to
log, show on a support screen, or quote to DeepID when reporting a failed
binding.
⚠️
bindingHashandsmsContentare the live verification tokenThey are what the SDK is about to send from the bound SIM. They are exposed so you can correlate a binding attempt server-side — that is a deliberate choice, not an oversight. Do not log them, do not persist them, and do not send the SMS yourself: a token sent from anywhere other than the bound SIM defeats exactly what SIM binding proves. Use
clientIdwhen all you need is something to correlate on.
toString()redacts both fields, so an accidentalprint(response)is safe.toMap()does not redact — calling it is an explicit decision to move the token somewhere.
SmsSentCheckResult
Delivered to onSmsSentCheck on startSimBinding() — Android only. The
verdict of the UPI 'SMS sent check' (see the
startSimBinding() docs for the flow and the
enforcement rules).
| Field | Type | Description |
|---|---|---|
checked |
bool? |
Whether the sent box was actually queried. false means reason says why not (READ_SMS missing, unreadable provider). |
validated |
bool? |
Whether a record matching the verification number and content was found. |
reason |
String? |
Why the check did not validate; null on success. Never carries the verification token, so the whole object is safe to log. |
matchedAtEpochMillis |
int? |
Provider timestamp of the matched record. |
fromMap never throws — wrong-typed fields read as null, same contract as
SimBindingInitResponse.
DeepIdException
Thrown by initialize() and startSimBinding(), and passed to error
callbacks. This is the only exception type the plugin lets cross its
boundary — a raw PlatformException, a MissingPluginException, or a
TypeError from a malformed native payload can never escape past a single
on DeepIdException handler. (verifySecurityPolicy() goes one step further
and never throws at all — see Unavailable.)
| Property | Type | Description |
|---|---|---|
code |
DeepIdErrorCode |
Typed error code. |
message |
String |
Human-readable description. |
nativeCode |
String? |
The raw platform error code as the native layer sent it; null for Dart-side errors. Identifies a native code this build cannot map (code == unknown). |
DeepIdErrorCode
| Code | When it occurs |
|---|---|
notInitialized |
An SDK method was called before initialize(). |
invalidAppKey |
appKey was blank when calling initialize(). |
invalidAppSecret |
appSecret was blank when calling initialize(). |
noContext |
Android: no Activity context available during initialize(). Call after the app is fully running. |
noViewController |
iOS: no root UIViewController available to present the sheet. |
initFailed |
SDK construction failed (network or server error during setup). |
enrollmentFailed |
Backend rejected the enrollment request. |
enrollmentTimeout |
Enrollment did not complete within the specified timeout. |
enrollmentNotComplete |
startSimBinding() called before onEnrollment has fired. |
simBindingFailed |
Verification flow failed on the carrier or server side. |
deviceIntelligenceFailed |
Fresh device intelligence request failed in the native SDK. |
alreadyInProgress |
startSimBinding() called while another SIM binding flow is still running. |
userCancelled |
User dismissed the native SIM binding sheet without completing. |
bindingAbandoned |
The customer left the app mid-binding — Home, Recents, an app switch, split-screen on Android, or the app going to background on iOS. A failed binding under UPI rules — offer a retry. |
loggedOut |
The operation was cancelled because logout() cleared the session it was waiting on. |
unsupportedPlatform |
The native plugin is not registered on this platform (web/desktop, or an unmocked test). Android and iOS are the only supported targets. |
malformedResponse |
The native layer answered on the success path with a payload this plugin version could not read — usually a plugin/native version mismatch. |
unknown |
Catch-all for unexpected native errors; nativeCode carries the raw code. |
Error handling
Three guarantees to build on:
- Every failure is a
DeepIdException. Oneon DeepIdExceptionhandler per call site sees everything that call can produce, with a typedcodeto branch on. No other exception type crosses the plugin boundary. - Callback failures are never dropped. If enrollment fails and you passed
no
onError, the failure is routed toFlutterError.reportError— it shows up in the console and in your crash reporting instead of vanishing. verifySecurityPolicy()never throws. No verdict is anUnavailableresult; treat it asBlockedfor critical operations.
Distinguish cancellation from failure
User cancellation is a normal UX path and should not be treated as an error:
try {
final result = await DeepId.startSimBinding();
// success path
} on DeepIdException catch (e) {
switch (e.code) {
case DeepIdErrorCode.userCancelled:
// User tapped back / dismissed — no error to show
break;
case DeepIdErrorCode.bindingAbandoned:
// User left the app mid-binding. The UPI rules make this a failed
// binding, not a dismissal — tell the user why and offer a retry.
_showRetryBanner('Stay in the app while we verify your SIM.');
break;
case DeepIdErrorCode.enrollmentNotComplete:
// Logic bug — show internal error or restart enrollment
break;
default:
_showErrorBanner(e.message);
}
}
Enrollment timeout
The default timeout is 150 seconds. Increase it on slow networks if needed, or lower it to surface failures faster:
await DeepId.initialize(
appKey: key,
appSecret: secret,
enrollmentTimeout: const Duration(seconds: 60),
onEnrollmentError: (e) {
if (e.code == DeepIdErrorCode.enrollmentTimeout) {
// Offer a "Retry" button that calls initialize() again
}
},
);
Retry after failure
initialize() is idempotent — calling it again reuses the existing native SDK
instance if it was already constructed successfully. If the initial SDK
construction failed, calling initialize() again creates a fresh instance.
Platform differences
deviceIntelligence field in EnrollmentResult
Both Android and iOS return device intelligence as an object when the native SDK
produces it during fresh enrollment. Existing-session or cached-session paths may
return null because no fresh payload is created in those flows.
Use DeepId.getFreshDeviceIntelligence() after enrollment when you need a live
device intelligence object instead of the enrollment-time value.
mobile field in SimBindingResult
| Platform | Value |
|---|---|
| Android | Phone number returned by the carrier after successful verification — guaranteed to be the real verified number. |
| iOS | The verified phone number from the server response if the backend returns one; otherwise the phoneNumber argument passed to startSimBinding(). Pass phoneNumber explicitly to ensure the field is populated even if the server omits it. |
Design your backend to treat the mobile value as informational on iOS, and
rely on deepId + sessionId as the authoritative binding proof.
Permissions
| Permission | Android | iOS |
|---|---|---|
| Phone / SIM access | READ_PHONE_STATE, READ_PHONE_NUMBERS (runtime, dangerous) |
No prompt — IDFV and CoreTelephony are available without permission |
| SMS sending + sent check | SEND_SMS, READ_SMS (runtime, dangerous — one SMS-group prompt covers both) |
No prompt — MessageUI framework used; no SMS store access exists |
| Motion / sensors | None | NSMotionUsageDescription in Info.plist (no prompt at runtime; CMMotionActivityManager prompt only if used) |
| Network | INTERNET, ACCESS_NETWORK_STATE, ACCESS_WIFI_STATE (install-time, normal) |
No prompt |
SIM binding UI
| Android | iOS | |
|---|---|---|
| Presentation | Full Activity launched via startActivityForResult |
UIHostingController presented as a .pageSheet |
| SIM selection | Jetpack Compose UI inside a transparent Activity |
SwiftUI DeepIdSDKEntryView |
| Verification SMS | Sent silently via SmsManager — the user never leaves the app |
Messages composer (MFMessageComposeViewController); the user taps send. Devices that cannot use the composer fall back to an sms: URL that opens the Messages app |
| 'SMS sent check' | Radio sentIntent confirmation, then sent-box validation — reported via onSmsSentCheck |
Composer's own sent result enforced; no sent-box access exists, onSmsSentCheck never fires |
| Dismissal detection | onActivityResult with RESULT_CANCELED |
UIAdaptivePresentationControllerDelegate + viewDidDisappear guard |
Leaving the app mid-binding
The UPI device-binding rules require the binding to be rejected when the
customer toggles away mid-flow. Both platforms enforce this deterministically
and surface it as DeepIdErrorCode.bindingAbandoned:
| Android | iOS | |
|---|---|---|
| App switch / Home / Recents | Rejected — instantly where the OEM delivers onUserLeaveHint, and in ~0.7 s via a process-level lifecycle backstop everywhere else |
Rejected the moment the app enters the background |
| Screen lock | Rejected via the same backstop | Rejected (entering background) |
| Split-screen | Rejected on entry — neither app-leave signal fires there | n/a |
| While the SMS composer is open | n/a — the send is silent, the customer never leaves the app | The composer is a system sheet inside the app, so leaving the app while it is open rejects the binding like any other departure, and the composer is torn down with the flow. After tapping send, control must return to the app within 5 s or the binding is declined |
| Trip to the Messages app to send the SMS | n/a | Exempt — only on devices where the in-app composer is unavailable (canSendText() is false) and the SDK hands off to the Messages app via an sms: URL. That trip is expected, so it does not reject; while the customer is in Messages the SDK cannot observe a further app switch |
| What does not trigger it | Notifications arriving (including the SMS-charge notice), permission dialogs, the notification shade, OEM SMS-confirmation dialogs, incoming-call banners | Control Center, incoming-call banners, notification banners — only actual backgrounding rejects |
Troubleshooting
Framework 'DeepIdSDK' not found (iOS linker)
Most commonly caused by the xcframework not being placed at the expected path. Verify it exists:
~/.pub-cache/hosted/pub.flutter-io.cn/deepidsdk_flutter-<version>/ios/Frameworks/DeepIdSDK.xcframework
(See Prerequisites.) Then clean the pod cache and reinstall:
cd ios
rm -rf Pods .symlinks Podfile.lock
pod install
The xcframework container name must also match the inner .framework name —
the pod expects DeepIdSDK.xcframework containing DeepIdSDK.framework.
If you received a differently named xcframework, rename the outer directory to
match before running pod install.
Framework 'ShieldPtr' not found (iOS linker, after upgrading to 2.0.0)
ShieldPtr.xcframework was removed in 2.0.0. This error means a stale
Podfile.lock, Pods/ directory, or manual Xcode embed is still referencing
it. Clear the cached copies:
flutter clean
rm -rf ~/.pub-cache/hosted/pub.flutter-io.cn/deepidsdk_flutter-*
cd ios && rm -rf Pods Podfile.lock && pod install
If you integrated manually rather than through CocoaPods, also remove the
ShieldPtr.xcframework entry from your target's Frameworks, Libraries, and
Embedded Content.
Gradle error: "DeepID Protect SDK AAR is missing or empty" (Android)
The build script requires the AAR at
~/.pub-cache/hosted/pub.flutter-io.cn/deepidsdk_flutter-<version>/android/libs/deepidsdk.aar.
This file is not included in the pub.flutter-io.cn package — you must obtain it from
DeepID (see Prerequisites) and place it at that exact path
before running flutter build or flutter run.
.swiftinterface error: 'X' is not a member type of class 'DeepIdSDK.X'
This is a Swift module/class name collision that occurs when a public class
shares its name with the containing module. The DeepIdSDK xcframework avoids
this by naming its public client class DeepIDSDK (distinct from the module
DeepIdSDK). If you see this error, verify that you are using an xcframework
built with BUILD_LIBRARY_FOR_DISTRIBUTION=YES and that the binary has not been
renamed.
App exits silently on initialize() (iOS device with an attached debugger)
Symptom. On a real iPhone, the app disappears within ~1 second of
DeepId.initialize() being called. flutter run typically reports Lost connection to device. (or just "Application finished"). No Dart exception is
thrown, no native crash report is generated, and nothing useful appears in
the device console. The same build works fine on the iOS Simulator and on
App Store / TestFlight / ad-hoc installs.
Cause. The release-built DeepIdSDK.xcframework includes an anti-debug
guard that calls exit(-1) whenever it sees a debugger attached to the
process (the kernel P_TRACED flag is set). This is fraud-prevention working
as designed — it blocks LLDB, Frida-with-debugger, and other ptrace-based
dynamic analysis on production devices. The catch is that flutter run to a
tethered device attaches LLDB for log forwarding, which trips the same guard.
End users who install your app via the App Store, TestFlight, MDM, or an ad-hoc IPA are not affected — their devices launch the app with no debugger attached, and the SDK runs normally.
Workarounds for development on hardware, in order of how close they are
to a normal flutter run loop:
-
Install once, then launch manually from the home screen. This is the simplest path and gives the same runtime conditions as a TestFlight build:
flutter build ios --release xcrun devicectl device install app \ --device <device-id> \ build/ios/iphoneos/Runner.appThen tap the app icon on the device. Hot reload is not available with this approach; for device logs, use
idevicesyslog, or Xcode → Window → Devices and Simulators → Open Console. -
Distribute via TestFlight or an ad-hoc IPA. Closest to production — no debugger ever attaches, and you exercise the same install path your end users will.
SIM binding fails: "could not be confirmed in this device's sent messages" (Android)
The UPI 'SMS sent check' ran and found no record of the verification SMS in the device's sent box — the SDK fails the binding by design, because it cannot show the token left this device. Before treating it as fraud, rule out the benign causes:
- The check only fails when the sent box was readable; a missing
READ_SMSgrant is reported aschecked: falseviaonSmsSentCheckand does not fail the binding. - A few OEM builds delay writing non-default-app sends into the SMS provider.
The SDK already polls for 6 seconds; if a device model reproducibly fails
here while the SMS genuinely arrives at the backend, capture the model and
report it to DeepID with the
onSmsSentCheckoutput.
bindingAbandoned the moment the app is left (both platforms)
Working as designed, not a bug: the UPI rules require rejecting the binding when the customer toggles away mid-flow. See Leaving the app mid-binding for exactly what triggers it — and what deliberately does not (notifications arriving, permission dialogs, incoming-call banners, and on iOS the SDK-initiated trip to the Messages app). When testing, complete the flow without switching apps or locking the screen.
- Request a debug build of the xcframework from DeepID. The debug
variant does not install the anti-debug guard, so
flutter runto a tethered device works normally with hot reload. Use the debug variant during active development and swap in the release variant before shipping. The two variants are produced by the SDK build script with and without the--releaseflag.
Enrollment times out on first run (Android)
On first install the SDK enrolls the device with the DeepID backend. On slow
networks this can exceed 30 seconds. Increase enrollmentTimeout and prompt
the user to check their network connection if enrollment fails.
NO_CONTEXT error on Android
initialize() must be called after the Flutter engine is attached to an
Activity. Calling it in a background isolate or before the first frame is
rendered will fail with this error. Place the call in initState() of your
root widget or inside WidgetsBinding.instance.addPostFrameCallback.
ALREADY_IN_PROGRESS error
Only one SIM binding flow can run at a time. If the user navigates away and
back, the original Future from the first startSimBinding() call is still
pending. Disable the "Bind SIM" button while startSimBinding() is in flight:
bool _simBindingInFlight = false;
// In your button:
onPressed: (_enrollmentReady && !_simBindingInFlight) ? _startSimBinding : null,
// In _startSimBinding:
setState(() => _simBindingInFlight = true);
try {
final result = await DeepId.startSimBinding();
// ...
} finally {
setState(() => _simBindingInFlight = false);
}
Libraries
- deepidsdk_flutter
- DeepID SDK Flutter Plugin