iOS v1.0.1.x
Specifications
| Spec | Value |
|---|---|
| SDK Version | 1.0.1.x |
| Min iOS | 13.0 (15.0 for the SDK build made with Xcode 27 — the xcode27 client build) |
| Swift | 5.5+ (async/await) |
| Xcode | 16.3+ (a build made with Xcode 27.1 is available) |
| Distribution | XCFramework |
| External Dependency | None (URLSession-based) |
What's new in 1.0.1.x
Migrating from 1.0.0.x
| Change | Kind | Action |
|---|---|---|
TSBehavioralConfig(bootstrapKeyId:bootstrapPublicKey:) | Breaking | Pass the bootstrap key id and public key provided by TrustVision. See Bootstrap key |
startCollect() is idempotent | Breaking | A call while a session is collecting is now a no-op. To start a new journey, call finalizeCollect() then startCollect(), or switchJourney() |
TSSessionError.bootstrapKeyNotConfigured removed | Breaking | Handle TSSessionError.invalidConfiguration(field:reason:), thrown by initialize(config:) |
clearDeviceIdentity() throws deprecated | Changed | Use try await clearDeviceIdentity() |
updateScreenTransition(screenName:) remembers the screen across sessions | Changed | You no longer need to call it again right after startCollect(); doing so records the screen twice |
switchJourney() | New | Finalizes the running session and opens a new one in a single call |
setCusUserId(_:), getRegistrationStatus(), reportRegistrationFailed(statusCode:cusUserId:), clearUser() | New | The SDK now keeps the cus_user_id, which cus_user_id the device is registered for, and a 10-minute cooldown after a rejected registration |
markDeviceRegistered(…, cusUserId:) | New | Optional parameter on both overloads; records the cus_user_id the device was registered for |
getEnabledFlag() / setEnabledFlag(_:) | New | Storage for your app's own on/off switch for the SDK |
TSBehavioralConfig.challengeTtlMillis | New | Optional. The SDK replaces the challenge before its nonce expires. See Challenge rotation |
| Device risk signals | New | No API change. Call state, screen capture, external displays and screenshot count are collected automatically |
Configuration — before (1.0.0.3):
let config = TSBehavioralConfig(
appId: "your-app-id",
baseUrl: "https://your-base-url/api"
)
Configuration — after (1.0.1.x):
let config = TSBehavioralConfig(
appId: "your-app-id",
baseUrl: "https://your-base-url/api",
bootstrapKeyId: "your-bootstrap-key-id", // Provided by TrustVision
bootstrapPublicKey: "your-bootstrap-public-key" // Provided by TrustVision
)
Starting a new journey — before (1.0.0.3): calling startCollect() again discarded the running session, including data not yet uploaded.
try await TSBehavioralSDK.shared.startCollect()
Starting a new journey — after (1.0.1.x): close the running session explicitly.
// Either: one call that closes the running session and opens the next
let previous = try await TSBehavioralSDK.shared.switchJourney()
// Or: two calls
let result = try await TSBehavioralSDK.shared.finalizeCollect()
try await TSBehavioralSDK.shared.startCollect()
Registration — before (1.0.0.3): your app decided when to register, from initInfo.isRegistered, and supplied its own cus_user_id.
let initInfo = try await TSBehavioralSDK.shared.initialize(config: config)
if !initInfo.isRegistered {
let userDeviceId = try await yourBackend.registerDevice(/* initInfo fields, cusUserId */)
try TSBehavioralSDK.shared.markDeviceRegistered(
isRegistered: true,
customerUserDeviceId: userDeviceId
)
}
Registration — after (1.0.1.x): the SDK says whether registration is needed and with which values.
try await TSBehavioralSDK.shared.initialize(config: config)
try await TSBehavioralSDK.shared.setCusUserId(signedInUserId)
if case .required(let request) = try await TSBehavioralSDK.shared.getRegistrationStatus() {
do {
let responseData = try await yourBackend.registerDevice(request)
try TSBehavioralSDK.shared.markDeviceRegistered(
encryptedPayload: responseData,
cusUserId: request.cusUserId
)
} catch {
try await TSBehavioralSDK.shared.reportRegistrationFailed(
statusCode: (error as? YourHTTPError)?.statusCode,
cusUserId: request.cusUserId
)
throw error
}
}
The 1.0.0.3 registration flow still works: initialize(config:) still returns TSDeviceInitInfo.isRegistered. If you previously kept the cus_user_id yourself and move to the new API, pass it once to setCusUserId(_:) before the first markDeviceRegistered so the existing backend profile is kept. The device then registers once more, because the SDK has no record yet of which cus_user_id it was registered for.
Installation
XCFramework
- Get the
BehaviorSDK.xcframeworkfrom TrustVision - Drag it into your Xcode project under Frameworks, Libraries, and Embedded Content
- Set Embed to Embed & Sign
Permissions (Info.plist)
Add the following keys if you enable Bluetooth or Location collection:
<!-- Required only if canAskPermission is true and Bluetooth is used -->
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Used for device behavioral analysis</string>
<!-- Required only if canAskPermission is true and Location is used -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Used for device behavioral analysis</string>
The device risk signals (call state, screen capture, external displays, screenshots) need no permission and no Info.plist key. Call-state detection uses CallKit; if your app cannot link CallKit (for example, an app distributed on the China App Store), ask TrustVision for a build without call-state collection.
Configuration
import BehaviorSDK
let config = TSBehavioralConfig(
appId: "your-app-id", // Your app's bundle identifier
baseUrl: "https://your-base-url/api", // Provided by TrustVision
bootstrapKeyId: "your-bootstrap-key-id", // Provided by TrustVision
bootstrapPublicKey: "your-bootstrap-public-key", // Provided by TrustVision
metadataConfig: MetaDataCollectionConfig(
intervalMillis: 2000,
canAskPermission: false,
enabled: true
),
uploadBlockSize: 10,
sensorConfig: SensorConfig(
samplingIntervalMs: 20,
enabled: true
),
keyboardConfig: KeyboardConfig(
trackRawText: false,
enabled: true
),
touchConfig: TouchConfig(
enabled: true
),
screenTransitionBlockSize: 1,
appStateBlockSize: 1,
retryConfig: RetryConfig(
enabled: true,
maxRetries: 2,
retryDelayMs: 1000,
maxDelayMs: 5000,
maxRetryAttempts: 3,
maxRetryStorageItems: 50,
finalFlushTimeoutSeconds: 10,
retryItemDelaySeconds: 0.5
),
challengeTtlMillis: 240_000
)
TSBehavioralConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
appId | String | Yes | — | Your app's bundle identifier |
baseUrl | String | Yes | — | Base URL for TrustVision API. Must be non-blank and start with http:// or https:// |
bootstrapKeyId | String | Yes | — | Identifier of the bootstrap key pair. Must be non-blank. See Bootstrap key |
bootstrapPublicKey | String | Yes | — | Public half of the bootstrap key pair, PEM or bare base64 DER, RSA-2048 or larger |
metadataConfig | MetaDataCollectionConfig | No | Default values | Device metadata collection settings |
uploadBlockSize | Int | No | 10 | Number of batch entries before auto-upload |
sensorConfig | SensorConfig | No | SensorConfig() | Sensor data collection settings |
keyboardConfig | KeyboardConfig | No | KeyboardConfig() | Keyboard tracking settings |
touchConfig | TouchConfig | No | TouchConfig() | Touch tracking settings |
screenTransitionBlockSize | Int | No | 1 | Screen transition batch size |
appStateBlockSize | Int | No | 1 | Number of app-state events per upload batch |
retryConfig | RetryConfig | No | RetryConfig() | Upload retry queue settings |
sslCertificates | [TSCertificate]? | No | nil | Certificates to pin. See SSL Pinning |
challengeTtlMillis | Int | No | 240000 (4 min) | How long a challenge is used before the SDK replaces it. Must be > 0. See Challenge rotation |
initialize(config:) validates baseUrl, bootstrapKeyId, bootstrapPublicKey and challengeTtlMillis, and throws TSSessionError.invalidConfiguration(field:reason:) naming the first field that is wrong.
Bootstrap key
bootstrapKeyId and bootstrapPublicKey identify the RSA key pair the TrustVision server registers devices with. TrustVision provides both; they must match the environment baseUrl points at.
Passing a different bootstrapKeyId on a later initialize(config:) is treated as a key rotation: initialize returns isRegistered = false, so the device must register again.
Challenge rotation
The server expires a challenge's nonce after five minutes; after that, uploaded batches are rejected and dropped. The SDK therefore replaces the running challenge every challengeTtlMillis (default 4 minutes) while a session is collecting: it drains the current challenge, creates a new one, and re-attaches the tracked text fields and the current screen. It also checks when the app returns to the foreground, so an app suspended past the deadline rotates before uploading. If creating the new challenge fails, the SDK retries up to five times with increasing delays.
A journey longer than challengeTtlMillis therefore produces several challenge IDs. finalizeCollect(), stopCollect() and switchJourney() return the challenge that is running when they are called. Only that challenge is scored today; scoring all of a journey's challenges together is in preview.
SSL Pinning
Optional certificate pinning. Pass pinned certificates via sslCertificates; when the array is non-empty the SDK trusts only servers presenting one of these certificates on every API call. nil or an empty array disables pinning and falls back to the system trust store. Each TSCertificate references a certificate file bundled in your app:
let config = TSBehavioralConfig(
appId: "com.example.app",
baseUrl: "https://api.trustingsocial.com",
bootstrapKeyId: "your-bootstrap-key-id",
bootstrapPublicKey: "your-bootstrap-public-key",
sslCertificates: [
TSCertificate(name: "my_server_cert", type: "der")
]
)
TSCertificate(name:type:bundle:) loads <name>.<type> from the given bundle (defaults to .main).
MetaDataCollectionConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
intervalMillis | Int | Yes | — | Metadata collection interval in milliseconds |
canAskPermission | Bool | No | false | Whether SDK may request system permissions (Bluetooth, Location) |
enabled | Bool | No | true | Enable/disable metadata collection |
typeModes | [MetaDataType: MetaDataCollectionMode] | No | deviceDescription→manual, deviceBehaviors→interval, userBehaviors→interval | Collection mode per metadata type. .interval = repeating timer; .manual = explicit calls only |
collectOnceTypes | Set<MetaDataType> | No | [.deviceDescription] | Types collected once at session start and stop (fire-and-forget boundary snapshots) |
SensorConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
samplingIntervalMs | Int | No | 20 (50Hz) | Sensor sampling interval in milliseconds. Must be > 0 |
enabled | Bool | No | true | Enable/disable sensor collection |
KeyboardConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
trackRawText | Bool | No | false | Whether to collect raw typed text. When false, only character type is recorded |
enabled | Bool | No | true | Enable/disable keyboard tracking |
blockSize | Int | No | 1 | Number of keyboard events per upload batch (≥ 1) |
TouchConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
enabled | Bool | No | true | Enable/disable touch tracking |
RetryConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
enabled | Bool | No | true | Enable/disable the retry queue |
maxRetries | Int | No | 2 | Max immediate retries per request (0–10). Total calls = value + 1 |
retryDelayMs | Int | No | 1000 | Base delay before first retry in milliseconds (> 0) |
maxDelayMs | Int | No | 5000 | Maximum delay cap in milliseconds (≥ retryDelayMs) |
maxRetryAttempts | Int | No | 3 | Maximum retry-queue attempts before dropping a failed batch (≥ 1) |
maxRetryStorageItems | Int | No | 50 | Maximum items in retry storage before FIFO eviction (≥ 1) |
finalFlushTimeoutSeconds | TimeInterval | No | 10.0 | Timeout in seconds for flush at session end (≥ 1) |
retryItemDelaySeconds | TimeInterval | No | 0.5 | Delay between retry uploads in seconds (≥ 0) |
SDK Functions
TSBehavioralSDK.shared is a singleton instance. Uses Swift async/await for asynchronous operations. Some methods require @MainActor.
The session operations — initialize, startCollect, switchJourney, finalizeCollect, clearUser, clearDeviceIdentity, and the SDK's own challenge rotations — run one at a time, in the order they were called. You can call them from several places without guarding against overlap: for example, two concurrent startCollect() calls open one session. stopCollect() is synchronous and applies immediately; a session operation called before it does not open a session after it.
| Property | Type | Description |
|---|---|---|
TSBehavioralSDK.sdkVersion | String | SDK build version, reported in every payload as config.sdk_version |
TSBehavioralSDK.shared.initializedConfig | TSBehavioralConfig? | The config of the last successful initialize; nil before that |
TSBehavioralSDK.shared.deviceInitInfo | TSDeviceInitInfo? | The device identity from the last initialize; nil before that, and after clearDeviceIdentity() |
initialize
Initializes the SDK. Must be called first before any other method. Generates device identity (EC key pair stored in Keychain) and returns device information needed for registration.
If previously initialized, the existing session is torn down (without uploading its remaining data) and the SDK is reinitialized. This happens before the new config is validated, so an invalidConfiguration error from a re-initialize has already ended the running session.
On the first launch after the app is installed, the SDK forgets the cus_user_id values a previous install left in the Keychain.
Signature:
@discardableResult
func initialize(config: TSBehavioralConfig) async throws -> TSDeviceInitInfo
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
config | TSBehavioralConfig | Yes | SDK configuration |
Returns: TSDeviceInitInfo
| Field | Type | Description |
|---|---|---|
deviceId | String | Unique device identifier (UUID) |
bootstrapKeyId | String | Key pair ID for encryption |
isRegistered | Bool | Whether device was previously registered (false after a bootstrap key change) |
encryptedDevicePublicKeyPayload | String | AES-GCM sealed device public key (base64). Pass to device_public_key in register API |
encryptedSecretKey | String | RSA-OAEP encrypted AES secret key (base64). Pass to encrypted_secret_key in register API |
Throws: TSSessionError.invalidConfiguration, TSSessionError.keychainError, TSSessionError.cryptoError
Example:
Task {
do {
let initInfo = try await TSBehavioralSDK.shared.initialize(
config: config
)
print("Device ID: \(initInfo.deviceId)")
} catch TSSessionError.invalidConfiguration(let field, let reason) {
print("Invalid \(field): \(reason)")
} catch let error as TSSessionError {
print("Init error [\(error.name)]: \(error.localizedDescription)")
}
}
setCusUserId
Sets the cus_user_id (your customer user identifier) the device is registered under. Call it on every sign-in, after initialize(config:).
A nil or blank value keeps the stored cus_user_id — for example, a biometric sign-in that has no user ID to give. If none was ever stored, getRegistrationStatus() generates one (a UUID) once and keeps it. Every call clears the registration cooldown.
Signature:
@MainActor
func setCusUserId(_ cusUserId: String?) async throws
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
cusUserId | String? | Yes | Your customer user identifier. nil or blank keeps the stored one |
Throws: TSSessionError.notInitialized, TSSessionError.keychainError
Example:
try await TSBehavioralSDK.shared.setCusUserId("your-user-id")
getRegistrationStatus
Tells you whether your app has to call its register-device API before startCollect(), and with which values.
Signature:
@MainActor
func getRegistrationStatus() async throws -> TSRegistrationStatus
Returns: TSRegistrationStatus
| Case | Meaning |
|---|---|
.registered | The device is registered for the current cus_user_id. Call startCollect() |
.coolingDown | A registration for this cus_user_id was rejected less than 10 minutes ago. Do not call register-device yet |
.required(TSRegistrationRequest) | Call register-device with the request's values, then markDeviceRegistered on success or reportRegistrationFailed on failure |
TSRegistrationRequest fields:
| Field | Type | Register API field |
|---|---|---|
cusUserId | String | cus_user_id |
deviceId | String | device_id |
bootstrapKeyId | String | key_pair_id |
encryptedDevicePublicKeyPayload | String | device_public_key |
encryptedSecretKey | String | encrypted_secret_key |
Throws: TSSessionError.notInitialized (also after clearDeviceIdentity(), until the next initialize), TSSessionError.keychainError
Example:
switch try await TSBehavioralSDK.shared.getRegistrationStatus() {
case .registered:
try await TSBehavioralSDK.shared.startCollect()
case .coolingDown:
break // Try again later
case .required(let request):
// Call your register-device API with the request's values
break
}
markDeviceRegistered
Marks the device as registered after your backend has completed server-side registration with TrustVision. Must be called after initialize() and before startCollect().
Registration status is persisted in Keychain. When it marks the device registered, the SDK also records the cus_user_id the device is registered for and clears the registration cooldown. Pass the cusUserId of the .required request you registered with, so a user who signs in while the call is in flight is not credited with it; nil records the current cus_user_id. If no cus_user_id was ever set or generated, nothing is recorded.
Signature (Option A — parsed values):
func markDeviceRegistered(isRegistered: Bool, customerUserDeviceId: String, cusUserId: String? = nil) throws
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
isRegistered | Bool | Yes | Registration status (true to mark as registered) |
customerUserDeviceId | String | Yes | Unique user-device identifier returned by your server |
cusUserId | String? | No | The cus_user_id the registration was made for. Default nil (current one) |
Signature (Option B — raw server response):
func markDeviceRegistered(encryptedPayload: Data, cusUserId: String? = nil) throws
| Parameter | Type | Required | Description |
|---|---|---|---|
encryptedPayload | Data | Yes | Raw response data from your server's registration API. SDK extracts customer_user_device_id from the JSON |
cusUserId | String? | No | The cus_user_id the registration was made for. Default nil (current one) |
Throws: TSSessionError.notInitialized, TSSessionError.keychainError
Example — using parsed values:
if case .required(let request) = try await TSBehavioralSDK.shared.getRegistrationStatus() {
// Your backend calls TrustVision API: POST /v1/register_device
// with: device_id, key_pair_id, device_public_key, encrypted_secret_key, cus_user_id, protocol
let userDeviceId = try await yourBackend.registerDevice(
deviceId: request.deviceId,
keyPairId: request.bootstrapKeyId,
devicePublicKey: request.encryptedDevicePublicKeyPayload,
encryptedSecretKey: request.encryptedSecretKey,
cusUserId: request.cusUserId
)
try TSBehavioralSDK.shared.markDeviceRegistered(
isRegistered: true,
customerUserDeviceId: userDeviceId,
cusUserId: request.cusUserId
)
}
Example — using raw server response:
let responseData = try await yourBackend.registerDeviceRaw(request)
// responseData is the raw Data from your server
// SDK parses: { "data": { "customer_user_device_id": "..." } }
try TSBehavioralSDK.shared.markDeviceRegistered(
encryptedPayload: responseData,
cusUserId: request.cusUserId
)
reportRegistrationFailed
Reports a failed register-device call. With an HTTP status code (your backend answered), the SDK starts a 10-minute cooldown for that cus_user_id, during which getRegistrationStatus() returns .coolingDown. With nil (no answer, e.g. a network failure), no cooldown starts.
The cooldown is kept in memory: it survives initialize(config:) but not an app restart. setCusUserId(_:), a successful markDeviceRegistered and clearUser() clear it.
Signature:
@MainActor
func reportRegistrationFailed(statusCode: Int?, cusUserId: String? = nil) async throws
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
statusCode | Int? | Yes | HTTP status your backend answered with; nil when there was no answer |
cusUserId | String? | No | The cus_user_id of the .required request that failed. Default nil (the current cus_user_id) |
Throws: TSSessionError.notInitialized, TSSessionError.keychainError
Example:
do {
let responseData = try await yourBackend.registerDeviceRaw(request)
try TSBehavioralSDK.shared.markDeviceRegistered(
encryptedPayload: responseData,
cusUserId: request.cusUserId
)
} catch {
try await TSBehavioralSDK.shared.reportRegistrationFailed(
statusCode: (error as? YourHTTPError)?.statusCode,
cusUserId: request.cusUserId
)
}
startCollect
Creates a challenge with the TrustVision server and starts all data collectors (sensors, metadata, touch, keyboard). Requires device to be registered.
Idempotent: if a session is already collecting, the call returns without doing anything — the running session, its challenge and its buffered data are left untouched. To start a new journey, call finalizeCollect() then startCollect(), or switchJourney().
The new session reports the screen last passed to updateScreenTransition(screenName:) and attaches the text fields registered with startKeyboardTracking while no session was collecting.
Signature:
@MainActor
func startCollect() async throws
Must be called on @MainActor (main thread).
Throws: TSSessionError.notInitialized, TSSessionError.deviceNotRegistered, TSSessionError.challengeExpired, TSSessionError.invalidChallengeResponse, TSSessionError.networkError
Example:
Task { @MainActor in
do {
try await TSBehavioralSDK.shared.startCollect()
print("Collection started")
} catch let error as TSSessionError {
switch error {
case .deviceNotRegistered:
print("Register device first")
case .networkError(let underlyingError):
print("Network error: \(underlyingError)")
default:
print("Error: \(error.name)")
}
}
}
switchJourney
Closes the running session exactly as finalizeCollect() does and opens a new one exactly as startCollect() does, as one operation — no other session call can run in between. The new session opens on the screen last passed to updateScreenTransition(screenName:), and the text fields you are tracking stay attached.
Signature:
@MainActor
func switchJourney() async throws -> TSFinalizeCollectResult?
Must be called on @MainActor (main thread).
Returns: the closed session's TSFinalizeCollectResult, or nil if nothing was collecting (in which case a session is simply started).
Throws: the same errors as startCollect(). If opening the new session fails, the previous session has already been finalized; its result is kept and returned by the next finalizeCollect(), stopCollect() or switchJourney() while nothing is collecting. A successful startCollect() discards it, so retry with switchJourney() to get it back. initialize, clearUser and clearDeviceIdentity also discard it.
Example:
Task { @MainActor in
do {
if let previous = try await TSBehavioralSDK.shared.switchJourney() {
// Score the journey that just ended
try await yourBackend.computeBehaviorScore(
challengeId: previous.challengeId
)
}
} catch let error as TSSessionError {
print("Switch error [\(error.name)]: \(error.localizedDescription)")
}
}
updateScreenTransition (Required)
Important: You must call
updateScreenTransition()whenever the user navigates to a new screen. This is required for behavioral data to be correctly associated with screen context.
Records a screen transition event. The SDK remembers the screen even while no session is collecting and reports it into every session startCollect() or switchJourney() opens, so a screen reported before startCollect() is not lost. You do not need to call it again right after startCollect(); doing so records the screen twice.
Non-throwing. Silently no-ops if SDK is not initialized.
Signature:
func updateScreenTransition(screenName: String)
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
screenName | String | Yes | Name of the screen being navigated to |
Example:
TSBehavioralSDK.shared.updateScreenTransition(screenName: "main_screen")
try await TSBehavioralSDK.shared.startCollect() // reports "main_screen"
// Then call again on every screen navigation
TSBehavioralSDK.shared.updateScreenTransition(screenName: "payment_screen")
Example — base view controller pattern (recommended):
class BehavioralBaseVC: UIViewController {
var screenName: String? { nil }
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if let name = screenName {
TSBehavioralSDK.shared.updateScreenTransition(
screenName: name
)
}
}
}
class PaymentVC: BehavioralBaseVC {
override var screenName: String? { "payment" }
}
class InputPinVC: BehavioralBaseVC {
override var screenName: String? { "input_pin" }
}
startKeyboardTracking
Registers a UITextField or UITextView for typing data capture. Automatically tracks typing patterns (character type, timing, text length).
A field registered while no session is collecting is attached when the next session opens. Registered fields stay attached across challenge rotations and switchJourney(); finalizeCollect() and stopCollect() detach them.
Note: Text fields with
isSecureTextEntry = trueare not supported. The SDK will not track secure text fields, as this would violate Apple's privacy policy. Do not pass secure text fields to this method.
Must be called on @MainActor. Non-throwing. Silently no-ops if SDK is not initialized.
Signatures:
@MainActor func startKeyboardTracking(textField: UITextField)
@MainActor func startKeyboardTracking(textView: UITextView)
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
textField | UITextField | Yes | Text field to track |
textView | UITextView | Yes | Text view to track |
Example:
class FormVC: UIViewController {
@IBOutlet weak var emailField: UITextField!
@IBOutlet weak var nameField: UITextField!
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Only track non-secure text fields
TSBehavioralSDK.shared.startKeyboardTracking(textField: emailField)
TSBehavioralSDK.shared.startKeyboardTracking(textField: nameField)
}
}
stopKeyboardTracking
Unregisters a specific UITextField or UITextView from typing tracking. Non-throwing. Silently no-ops if SDK is not initialized. Not main-actor isolated, so it can be called from deinit.
Signatures:
func stopKeyboardTracking(textField: UITextField)
func stopKeyboardTracking(textView: UITextView)
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
textField | UITextField | Yes | Text field to stop tracking |
textView | UITextView | Yes | Text view to stop tracking |
Example:
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
TSBehavioralSDK.shared.stopKeyboardTracking(textField: emailField)
TSBehavioralSDK.shared.stopKeyboardTracking(textField: nameField)
}
addKeyboardTrackingData
Sends keyboard tracking data manually. Use this for cross-platform integrations (Flutter, React Native) where native UIKit tracking is not available.
Non-throwing. Silently no-ops if SDK is not initialized.
Signature:
func addKeyboardTrackingData(_ data: TSTypingData)
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
data | TSTypingData | Yes | Keyboard event data |
TSTypingData fields:
| Field | Type | Required | Description |
|---|---|---|---|
timestamp | Int64 | Yes | Event timestamp in milliseconds since epoch |
textLength | Int? | No | Current text field length |
data | String? | No | Character value (only if trackRawText is true) |
type | TSKeyboardDataType | Yes | Type of keystroke |
viewId | String | Yes | Identifier for the input field |
TSKeyboardDataType values: lowerText, upperText, number, spacing, specialCharacters, delete, cursorMoving, unknown, mixed
Example:
TSBehavioralSDK.shared.addKeyboardTrackingData(
TSTypingData(
timestamp: Int64(Date().timeIntervalSince1970 * 1000),
textLength: 5,
data: nil,
type: .lowerText,
viewId: "email_field"
)
)
trackTextChange
Stateless, framework-agnostic keyboard tracking entry point for SwiftUI text fields where UITextField/UITextView delegates are unavailable. Pass old and new text on every onChange — the SDK computes the diff and classifies the keystroke type internally.
Do not call it for a SecureField: the SDK cannot detect secure entry and records the inserted text if trackRawText is enabled.
Non-throwing. Silently no-ops if SDK is not initialized.
Signature:
func trackTextChange(oldText: String, newText: String, viewId: String)
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
oldText | String | Yes | Text value before the change |
newText | String | Yes | Text value after the change |
viewId | String | Yes | Stable identifier for the input field |
Example — SwiftUI (iOS 14–16):
struct LoginView: View {
@State private var email = ""
@State private var previousEmail = ""
var body: some View {
TextField("Email", text: $email)
.onChange(of: email) { newValue in
TSBehavioralSDK.shared.trackTextChange(
oldText: previousEmail,
newText: newValue,
viewId: "email_field"
)
previousEmail = newValue
}
}
}
Example — SwiftUI (iOS 17+):
TextField("Email", text: $email)
.onChange(of: email) { oldValue, newValue in
TSBehavioralSDK.shared.trackTextChange(
oldText: oldValue,
newText: newValue,
viewId: "email_field"
)
}
trackTouchEvent
Records a touch event from cross-platform pointer data (Flutter, React Native). Non-throwing. Silently no-ops if SDK is not initialized.
For native iOS apps, touch tracking is handled automatically by the SDK when touchConfig.enabled is true.
Signature:
func trackTouchEvent(event: TSPointerEventDto)
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
event | TSPointerEventDto | Yes | Touch event data |
TSPointerEventDto fields:
| Field | Type | Description |
|---|---|---|
actionName | String? | Action name: "down", "move", "up" |
actionMasked | Int? | Action constant |
pointerId | Int? | Pointer identifier |
globalX | Double? | Screen X coordinate |
globalY | Double? | Screen Y coordinate |
Example:
TSBehavioralSDK.shared.trackTouchEvent(
event: TSPointerEventDto(
actionName: "down",
actionMasked: 0,
pointerId: 0,
globalX: 150.0,
globalY: 300.0
)
)
finalizeCollect
Stops all collectors, flushes remaining data, and waits for the upload queue to drain completely. This is the clean shutdown — ensures all collected data is uploaded before returning. Detaches the tracked text fields; the last reported screen is kept for the next session.
Pass the returned challengeId to your backend to trigger behavior score computation via POST /v1/behavior/score.
Signature:
@MainActor
func finalizeCollect() async throws -> TSFinalizeCollectResult
Must be called on @MainActor (main thread).
Returns: TSFinalizeCollectResult
| Field | Type | Description |
|---|---|---|
challengeId | String | Challenge ID — pass to backend for scoring |
nonce | String | Session nonce |
lastSeq | Int | Last uploaded batch sequence number |
batchId | String? | Last batch identifier (nil if no batches uploaded) |
droppedBatchCount | Int | Number of event batches dropped without reaching the server (data loss). Defaults to 0; 0 on a clean finalize |
lastUploadError | String? | Description of the last upload error that caused a drop, or nil when none |
droppedBatchCount and lastUploadError let you detect silent data loss after finalize. A non-zero droppedBatchCount means some collected batches never reached the server; inspect lastUploadError for the cause.
Throws: TSSessionError.notInitialized, TSSessionError.notStarted
Example:
Task { @MainActor in
do {
let result = try await TSBehavioralSDK.shared.finalizeCollect()
print("Challenge ID: \(result.challengeId)")
print("Last seq: \(result.lastSeq)")
if result.droppedBatchCount > 0 {
print("Dropped \(result.droppedBatchCount) batches. Last error: \(result.lastUploadError ?? "nil")")
}
// Send challengeId to your backend for scoring
try await yourBackend.computeBehaviorScore(
challengeId: result.challengeId
)
} catch let error as TSSessionError {
print("Finalize error [\(error.name)]: \(error.localizedDescription)")
}
}
stopCollect
Stops all collectors immediately without flushing data or draining the upload queue. This is the fast shutdown — use when you need to stop quickly (e.g., app backgrounding). May result in partial data loss. Detaches the tracked text fields; the last reported screen is kept for the next session.
stopCollect() does not wait for other session operations. It applies at once and is the last word: a startCollect() or switchJourney() called before it does not open a session after it.
Signature:
@MainActor
func stopCollect() throws -> TSFinalizeCollectResult
Must be called on @MainActor (main thread). Synchronous (not async).
Returns: TSFinalizeCollectResult (same structure as finalizeCollect, including the droppedBatchCount and lastUploadError observability fields). Because stopCollect() skips the flush and upload-queue drain, a non-zero droppedBatchCount is expected here.
Throws: TSSessionError.notInitialized, TSSessionError.notStarted
finalizeCollect() | stopCollect() | |
|---|---|---|
| Async | async throws | throws (sync) |
| Flush data | Yes | No |
| Drain upload queue | Yes (waits) | No |
| Data loss risk | None | Possible |
| Use case | Normal completion | App backgrounding |
Example:
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if isMovingFromParent {
do {
let result = try TSBehavioralSDK.shared.stopCollect()
print("Stopped. Last seq: \(result.lastSeq)")
} catch {
print("Stop error: \(error)")
}
}
}
clearUser
Logout. Forgets the cus_user_id, which cus_user_id the device is registered for, the server device ID and the registration flag, and clears the registration cooldown. The device identity (deviceId) is kept — use clearDeviceIdentity() to remove that too.
A running session ends without uploading its remaining data, and the remembered screen and tracked text fields are forgotten. Call finalizeCollect() first to keep the session's data.
Signature:
@MainActor
func clearUser() async throws
Throws: TSSessionError.notInitialized, TSSessionError.keychainError
Example:
func onLogout() async {
_ = try? await TSBehavioralSDK.shared.finalizeCollect()
try? await TSBehavioralSDK.shared.clearUser()
}
clearDeviceIdentity
Clears all stored device identity (device ID, registration status, server keys) from Keychain and session state. A running session ends without uploading its remaining data, and the remembered screen is forgotten. You must call initialize() again after this to generate a new device identity; until then getRegistrationStatus() throws notInitialized.
Use when you need to reset the SDK completely. For an ordinary logout, use clearUser().
Signature:
@MainActor
func clearDeviceIdentity() async throws
Throws: TSSessionError.notInitialized, TSSessionError.keychainError
Example:
do {
try await TSBehavioralSDK.shared.clearDeviceIdentity()
print("Device identity cleared")
// Must call initialize() again before using SDK
} catch {
print("Clear error: \(error)")
}
getEnabledFlag / setEnabledFlag
Storage for your app's own on/off switch for the SDK — for example, a remote kill switch you cache to apply on the next launch. The SDK only stores the value and does not act on it; what "off" means (such as skipping initialize) is up to your app.
The flag can be read before initialize. It is stored in UserDefaults, so it is removed when the app is deleted, and it survives clearUser() and clearDeviceIdentity().
Signatures:
func getEnabledFlag() -> Bool?
func setEnabledFlag(_ enabled: Bool?)
getEnabledFlag() returns nil if no value was ever stored (not the same as false). setEnabledFlag(nil) removes the stored value.
Example:
if TSBehavioralSDK.shared.getEnabledFlag() != false {
try await TSBehavioralSDK.shared.initialize(config: config)
}
// Later, when your remote config changes
TSBehavioralSDK.shared.setEnabledFlag(remoteConfig.behavioralEnabled)
Error Handling
All errors are cases of the TSSessionError enum conforming to Error. Each error has a name property for machine-readable identification.
| Error | name | Condition |
|---|---|---|
notInitialized | notInitialized | SDK not initialized. Call initialize(config:) first |
invalidConfiguration(field:reason:) | invalidConfiguration | A TSBehavioralConfig value is blank or unusable (baseUrl, bootstrapKeyId, bootstrapPublicKey, challengeTtlMillis). Thrown by initialize(config:) |
deviceNotRegistered | deviceNotRegistered | Server-authoritative: the server reports the device is not registered. Call markDeviceRegistered() after registering with your backend |
sslPinningError(Error) | sslPinningError | TLS/SSL pinning validation failed — the server certificate did not match a pinned certificate |
challengeExpired | challengeExpired | Server challenge expired. Retry startCollect() |
invalidChallengeResponse | invalidChallengeResponse | Invalid or empty challenge from server |
notStarted | notStarted | No active collection session. Call startCollect() first |
keychainError(Error) | keychainError | Keychain read/write failed |
cryptoError(Error) | cryptoError | Cryptographic operation failed |
serializationError(Error) | serializationError | Failed to encode/decode a request or response payload |
networkError(Error) | networkError | Network request failed. The wrapped Error is an ApiError (see below) |
unknownError(Error) | unknownError | An unclassified error occurred |
bootstrapKeyNotConfigured from 1.0.0.x no longer exists; a missing or malformed bootstrap key is reported as invalidConfiguration.
For networkError(Error), the wrapped error is an ApiError. ApiError.server(statusCode:data:) carries the HTTP status code, and ApiError exposes serverErrorCode: String? and serverErrorMessage: String? — mirroring the server's errors[].code and errors[].message (see the API reference).
do {
try await TSBehavioralSDK.shared.startCollect()
} catch let error as TSSessionError {
switch error {
case .deviceNotRegistered:
// Server says the device is not registered — register via your backend first
print("Register device first")
case .sslPinningError(let underlyingError):
print("SSL pinning failed: \(underlyingError)")
case .networkError(let underlyingError):
if let apiError = underlyingError as? ApiError {
print("Network error [\(apiError.serverErrorCode ?? "-")]: \(apiError.serverErrorMessage ?? "")")
} else {
print("Network error: \(underlyingError)")
}
default:
print("SDK error [\(error.name)]: \(error.localizedDescription)")
}
}
Complete Example
A full view controller integrating all SDK functions: initialization, user identity, device registration, data collection, keyboard tracking, screen transitions, journey switching, finalization and logout.
import UIKit
import BehaviorSDK
class BehavioralViewController: UIViewController {
@IBOutlet weak var emailTextField: UITextField!
@IBOutlet weak var passwordTextField: UITextField!
@IBOutlet weak var submitButton: UIButton!
private var collectTask: Task<Void, Never>?
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
collectTask = Task { @MainActor in
await setupAndStartCollection()
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if isMovingFromParent {
collectTask?.cancel()
// Fast stop if user navigates away
_ = try? TSBehavioralSDK.shared.stopCollect()
}
}
// MARK: - SDK Setup
func setupAndStartCollection() async {
let sdk = TSBehavioralSDK.shared
do {
// 1. Configure SDK
let config = TSBehavioralConfig(
appId: "your-app-id",
baseUrl: "https://your-base-url/api",
bootstrapKeyId: "your-bootstrap-key-id",
bootstrapPublicKey: "your-bootstrap-public-key",
metadataConfig: MetaDataCollectionConfig(
intervalMillis: 2000
),
sensorConfig: SensorConfig(
samplingIntervalMs: 20
),
keyboardConfig: KeyboardConfig(
trackRawText: false
),
touchConfig: TouchConfig(
enabled: true
)
)
// 2. Initialize SDK
try await sdk.initialize(config: config)
// 3. Tell the SDK who signed in
try await sdk.setCusUserId("your-user-id")
// 4. Register device if the SDK says so
switch try await sdk.getRegistrationStatus() {
case .registered:
break
case .coolingDown:
return // A registration was rejected recently; try again later
case .required(let request):
do {
// Your backend calls:
// POST /v1/register_device
// Body: {
// "device_id": request.deviceId,
// "key_pair_id": request.bootstrapKeyId,
// "device_public_key": request.encryptedDevicePublicKeyPayload,
// "encrypted_secret_key": request.encryptedSecretKey,
// "cus_user_id": request.cusUserId,
// "protocol": "encrypted"
// }
// Response: { "data": { "customer_user_device_id": "..." } }
let userDeviceId = try await yourBackend.registerDevice(
deviceId: request.deviceId,
keyPairId: request.bootstrapKeyId,
devicePublicKey: request.encryptedDevicePublicKeyPayload,
encryptedSecretKey: request.encryptedSecretKey,
cusUserId: request.cusUserId
)
try sdk.markDeviceRegistered(
isRegistered: true,
customerUserDeviceId: userDeviceId,
cusUserId: request.cusUserId
)
} catch {
try await sdk.reportRegistrationFailed(
statusCode: (error as? YourHTTPError)?.statusCode,
cusUserId: request.cusUserId
)
throw error
}
}
// 5. Report the current screen (remembered and reported into the session)
sdk.updateScreenTransition(screenName: "login_screen")
// 6. Start keyboard tracking (attached when the session opens)
sdk.startKeyboardTracking(textField: emailTextField)
// 7. Start data collection (no-op if a session is already collecting)
try await sdk.startCollect()
} catch let error as TSSessionError {
print("SDK error [\(error.name)]: \(error.localizedDescription)")
} catch {
print("Unexpected error: \(error)")
}
}
// MARK: - Screen Navigation
func navigateToPayment() {
// 8. Track screen transitions on navigation
TSBehavioralSDK.shared.updateScreenTransition(
screenName: "payment_screen"
)
// ... navigate to payment screen
}
// MARK: - New journey on the same screen
func startNewJourney() {
Task { @MainActor in
// 9. Close the running session and open the next one in one call
if let previous = try? await TSBehavioralSDK.shared.switchJourney() {
try? await yourBackend.computeBehaviorScore(
challengeId: previous.challengeId
)
}
}
}
// MARK: - Finalize
@IBAction func submitTapped(_ sender: UIButton) {
Task { @MainActor in
do {
// 10. Finalize — flushes data, drains upload queue, detaches text fields
let result = try await TSBehavioralSDK.shared.finalizeCollect()
// Send challengeId to your backend to compute behavior score:
// POST /v1/behavior/score
// Body: { "challenge_id": result.challengeId }
try await yourBackend.computeBehaviorScore(
challengeId: result.challengeId
)
} catch let error as TSSessionError {
print("Finalize error [\(error.name)]: \(error.localizedDescription)")
}
}
}
// MARK: - Logout
func onLogout() {
Task { @MainActor in
// 11. Forget the signed-in user; the device identity is kept
do {
try await TSBehavioralSDK.shared.clearUser()
} catch {
print("Clear user error: \(error)")
}
}
}
}
Server-Side Device Registration
When getRegistrationStatus() returns .required, your app must register the device with TrustVision backend before calling startCollect(). This is a server-side call — typically done from your app via an authenticated API client.
API Endpoint
POST {baseUrl}/v1/register_device
Content-Type: application/json
Authorization: TV {accessKeyId}:{signature}
X-TV-Timestamp: {ISO8601 UTC timestamp}
Request Body
request is the TSRegistrationRequest from .required(request).
| Field | Type | Source | Description |
|---|---|---|---|
device_id | String | request.deviceId | SDK-generated device identifier |
key_pair_id | String | request.bootstrapKeyId | Bootstrap key pair identifier |
device_public_key | String | request.encryptedDevicePublicKeyPayload | AES-GCM sealed device public key (base64) |
encrypted_secret_key | String | request.encryptedSecretKey | RSA-OAEP encrypted AES secret key (base64) |
cus_user_id | String | request.cusUserId | Customer user identifier |
protocol | String | — | Always "encrypted" |
{
"device_id": "from request.deviceId",
"key_pair_id": "from request.bootstrapKeyId",
"device_public_key": "from request.encryptedDevicePublicKeyPayload",
"encrypted_secret_key": "from request.encryptedSecretKey",
"cus_user_id": "from request.cusUserId",
"protocol": "encrypted"
}
Response
{
"data": {
"customer_user_device_id": "server-assigned-id"
}
}
HMAC-SHA256 Authentication
All API requests require HMAC-SHA256 signature in the Authorization header.
String to sign:
{METHOD}\n{PATH}\n{TIMESTAMP}
METHOD— HTTP method (e.g.,POST)PATH— API path without base URL (e.g.,/v1/register_device)TIMESTAMP— ISO 8601 UTC timestamp (e.g.,2026-03-31T12:00:00.000+0000)
Signature: Base64(HMAC-SHA256(accessKeySecret, stringToSign))
Headers:
| Header | Value |
|---|---|
Authorization | TV {accessKeyId}:{signature} |
X-TV-Timestamp | ISO 8601 UTC timestamp |
Swift Example
import Foundation
import CryptoKit
import BehaviorSDK
struct DemoApiClient {
let endpoint: String
let apiKeyId: String
let apiSecretKey: String
struct HTTPError: Error {
let statusCode: Int
}
// MARK: - Register Device
/// Returns the raw response body, for `markDeviceRegistered(encryptedPayload:cusUserId:)`.
func registerDevice(_ request: TSRegistrationRequest) async throws -> Data {
let path = "/v1/register_device"
guard let url = URL(string: endpoint + path) else {
throw URLError(.badURL)
}
let body: [String: String] = [
"cus_user_id": request.cusUserId,
"device_id": request.deviceId,
"key_pair_id": request.bootstrapKeyId,
"device_public_key": request.encryptedDevicePublicKeyPayload,
"encrypted_secret_key": request.encryptedSecretKey,
"protocol": "encrypted",
]
let jsonData = try JSONSerialization.data(withJSONObject: body)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.httpBody = jsonData
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
applyAuthHeaders(to: &urlRequest, method: "POST", path: path)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200...299).contains(httpResponse.statusCode) else {
throw HTTPError(statusCode: httpResponse.statusCode)
}
return data
}
// MARK: - HMAC-SHA256 Signing
private func applyAuthHeaders(
to request: inout URLRequest,
method: String,
path: String
) {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let timestamp = formatter.string(from: Date())
let stringToSign = "\(method)\n\(path)\n\(timestamp)"
let hmac = HMAC<SHA256>.authenticationCode(
for: Data(stringToSign.utf8),
using: SymmetricKey(data: Data(apiSecretKey.utf8))
)
let signature = Data(hmac).base64EncodedString()
request.setValue(timestamp, forHTTPHeaderField: "X-TV-Timestamp")
request.setValue("TV \(apiKeyId):\(signature)", forHTTPHeaderField: "Authorization")
}
}
Full Registration Flow
let sdk = TSBehavioralSDK.shared
// 1. Initialize SDK
let config = TSBehavioralConfig(
appId: "your-app-id",
baseUrl: "https://your-trustvision-endpoint.com/api",
bootstrapKeyId: "your-bootstrap-key-id",
bootstrapPublicKey: "your-bootstrap-public-key"
)
try await sdk.initialize(config: config)
try await sdk.setCusUserId("your-user-id")
// 2. Register device if required
if case .required(let request) = try await sdk.getRegistrationStatus() {
let client = DemoApiClient(
endpoint: "https://your-trustvision-endpoint.com/api",
apiKeyId: "your-access-key-id",
apiSecretKey: "your-access-key-secret"
)
do {
let responseData = try await client.registerDevice(request)
// 3. Mark device as registered in SDK
try sdk.markDeviceRegistered(
encryptedPayload: responseData,
cusUserId: request.cusUserId
)
} catch {
try await sdk.reportRegistrationFailed(
statusCode: (error as? DemoApiClient.HTTPError)?.statusCode,
cusUserId: request.cusUserId
)
throw error
}
}
// 4. Now you can start collecting
try await sdk.startCollect()