iOS v1.0.1.x

Specifications

SpecValue
SDK Version1.0.1.x
Min iOS13.0 (15.0 for the SDK build made with Xcode 27 — the xcode27 client build)
Swift5.5+ (async/await)
Xcode16.3+ (a build made with Xcode 27.1 is available)
DistributionXCFramework
External DependencyNone (URLSession-based)
Building your app with Xcode 27
Use the `xcode27` build of the SDK, which requires iOS 15.0 or later. iOS 27 requires apps built with Xcode 27 to adopt the UIScene life cycle (a `SceneDelegate` and a scene manifest in `Info.plist`); an app without it does not launch on iOS 27.

What's new in 1.0.1.x

Migrating from 1.0.0.x

Breaking changes
`TSBehavioralConfig` has two new required parameters, `startCollect()` no longer restarts a running session, and `bootstrapKeyNotConfigured` is replaced by `invalidConfiguration`. Review each item below before upgrading.
ChangeKindAction
TSBehavioralConfig(bootstrapKeyId:bootstrapPublicKey:)BreakingPass the bootstrap key id and public key provided by TrustVision. See Bootstrap key
startCollect() is idempotentBreakingA call while a session is collecting is now a no-op. To start a new journey, call finalizeCollect() then startCollect(), or switchJourney()
TSSessionError.bootstrapKeyNotConfigured removedBreakingHandle TSSessionError.invalidConfiguration(field:reason:), thrown by initialize(config:)
clearDeviceIdentity() throws deprecatedChangedUse try await clearDeviceIdentity()
updateScreenTransition(screenName:) remembers the screen across sessionsChangedYou no longer need to call it again right after startCollect(); doing so records the screen twice
switchJourney()NewFinalizes the running session and opens a new one in a single call
setCusUserId(_:), getRegistrationStatus(), reportRegistrationFailed(statusCode:cusUserId:), clearUser()NewThe 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:)NewOptional parameter on both overloads; records the cus_user_id the device was registered for
getEnabledFlag() / setEnabledFlag(_:)NewStorage for your app's own on/off switch for the SDK
TSBehavioralConfig.challengeTtlMillisNewOptional. The SDK replaces the challenge before its nonce expires. See Challenge rotation
Device risk signalsNewNo API change. Call state, screen capture, external displays and screenshot count are collected automatically

Configuration — before (1.0.0.3):

Swift
let config = TSBehavioralConfig(
    appId: "your-app-id",
    baseUrl: "https://your-base-url/api"
)

Configuration — after (1.0.1.x):

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

Swift
try await TSBehavioralSDK.shared.startCollect()

Starting a new journey — after (1.0.1.x): close the running session explicitly.

Swift
// 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.

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

Swift
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

  1. Get the BehaviorSDK.xcframework from TrustVision
  2. Drag it into your Xcode project under Frameworks, Libraries, and Embedded Content
  3. Set Embed to Embed & Sign

Permissions (Info.plist)

Add the following keys if you enable Bluetooth or Location collection:

XML
<!-- 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

Swift
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

FieldTypeRequiredDefaultDescription
appIdStringYes—Your app's bundle identifier
baseUrlStringYes—Base URL for TrustVision API. Must be non-blank and start with http:// or https://
bootstrapKeyIdStringYes—Identifier of the bootstrap key pair. Must be non-blank. See Bootstrap key
bootstrapPublicKeyStringYes—Public half of the bootstrap key pair, PEM or bare base64 DER, RSA-2048 or larger
metadataConfigMetaDataCollectionConfigNoDefault valuesDevice metadata collection settings
uploadBlockSizeIntNo10Number of batch entries before auto-upload
sensorConfigSensorConfigNoSensorConfig()Sensor data collection settings
keyboardConfigKeyboardConfigNoKeyboardConfig()Keyboard tracking settings
touchConfigTouchConfigNoTouchConfig()Touch tracking settings
screenTransitionBlockSizeIntNo1Screen transition batch size
appStateBlockSizeIntNo1Number of app-state events per upload batch
retryConfigRetryConfigNoRetryConfig()Upload retry queue settings
sslCertificates[TSCertificate]?NonilCertificates to pin. See SSL Pinning
challengeTtlMillisIntNo240000 (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.

Compile the bootstrap public key into your app
Do not fetch `bootstrapPublicKey` at runtime — not from remote settings, your backend or a config endpoint. It is the trust anchor for device registration: whoever controls the response carrying it can substitute their own key and complete registration in the server's place.

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:

Swift
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

FieldTypeRequiredDefaultDescription
intervalMillisIntYes—Metadata collection interval in milliseconds
canAskPermissionBoolNofalseWhether SDK may request system permissions (Bluetooth, Location)
enabledBoolNotrueEnable/disable metadata collection
typeModes[MetaDataType: MetaDataCollectionMode]NodeviceDescription→manual, deviceBehaviors→interval, userBehaviors→intervalCollection mode per metadata type. .interval = repeating timer; .manual = explicit calls only
collectOnceTypesSet<MetaDataType>No[.deviceDescription]Types collected once at session start and stop (fire-and-forget boundary snapshots)

SensorConfig

FieldTypeRequiredDefaultDescription
samplingIntervalMsIntNo20 (50Hz)Sensor sampling interval in milliseconds. Must be > 0
enabledBoolNotrueEnable/disable sensor collection

KeyboardConfig

FieldTypeRequiredDefaultDescription
trackRawTextBoolNofalseWhether to collect raw typed text. When false, only character type is recorded
enabledBoolNotrueEnable/disable keyboard tracking
blockSizeIntNo1Number of keyboard events per upload batch (≥ 1)

TouchConfig

FieldTypeRequiredDefaultDescription
enabledBoolNotrueEnable/disable touch tracking

RetryConfig

FieldTypeRequiredDefaultDescription
enabledBoolNotrueEnable/disable the retry queue
maxRetriesIntNo2Max immediate retries per request (0–10). Total calls = value + 1
retryDelayMsIntNo1000Base delay before first retry in milliseconds (> 0)
maxDelayMsIntNo5000Maximum delay cap in milliseconds (≥ retryDelayMs)
maxRetryAttemptsIntNo3Maximum retry-queue attempts before dropping a failed batch (≥ 1)
maxRetryStorageItemsIntNo50Maximum items in retry storage before FIFO eviction (≥ 1)
finalFlushTimeoutSecondsTimeIntervalNo10.0Timeout in seconds for flush at session end (≥ 1)
retryItemDelaySecondsTimeIntervalNo0.5Delay 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.

PropertyTypeDescription
TSBehavioralSDK.sdkVersionStringSDK build version, reported in every payload as config.sdk_version
TSBehavioralSDK.shared.initializedConfigTSBehavioralConfig?The config of the last successful initialize; nil before that
TSBehavioralSDK.shared.deviceInitInfoTSDeviceInitInfo?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:

Swift
@discardableResult
func initialize(config: TSBehavioralConfig) async throws -> TSDeviceInitInfo

Parameters:

ParameterTypeRequiredDescription
configTSBehavioralConfigYesSDK configuration

Returns: TSDeviceInitInfo

FieldTypeDescription
deviceIdStringUnique device identifier (UUID)
bootstrapKeyIdStringKey pair ID for encryption
isRegisteredBoolWhether device was previously registered (false after a bootstrap key change)
encryptedDevicePublicKeyPayloadStringAES-GCM sealed device public key (base64). Pass to device_public_key in register API
encryptedSecretKeyStringRSA-OAEP encrypted AES secret key (base64). Pass to encrypted_secret_key in register API

Throws: TSSessionError.invalidConfiguration, TSSessionError.keychainError, TSSessionError.cryptoError

Example:

Swift
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:

Swift
@MainActor
func setCusUserId(_ cusUserId: String?) async throws

Parameters:

ParameterTypeRequiredDescription
cusUserIdString?YesYour customer user identifier. nil or blank keeps the stored one

Throws: TSSessionError.notInitialized, TSSessionError.keychainError

Example:

Swift
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:

Swift
@MainActor
func getRegistrationStatus() async throws -> TSRegistrationStatus

Returns: TSRegistrationStatus

CaseMeaning
.registeredThe device is registered for the current cus_user_id. Call startCollect()
.coolingDownA 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:

FieldTypeRegister API field
cusUserIdStringcus_user_id
deviceIdStringdevice_id
bootstrapKeyIdStringkey_pair_id
encryptedDevicePublicKeyPayloadStringdevice_public_key
encryptedSecretKeyStringencrypted_secret_key

Throws: TSSessionError.notInitialized (also after clearDeviceIdentity(), until the next initialize), TSSessionError.keychainError

Example:

Swift
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):

Swift
func markDeviceRegistered(isRegistered: Bool, customerUserDeviceId: String, cusUserId: String? = nil) throws

Parameters:

ParameterTypeRequiredDescription
isRegisteredBoolYesRegistration status (true to mark as registered)
customerUserDeviceIdStringYesUnique user-device identifier returned by your server
cusUserIdString?NoThe cus_user_id the registration was made for. Default nil (current one)

Signature (Option B — raw server response):

Swift
func markDeviceRegistered(encryptedPayload: Data, cusUserId: String? = nil) throws
ParameterTypeRequiredDescription
encryptedPayloadDataYesRaw response data from your server's registration API. SDK extracts customer_user_device_id from the JSON
cusUserIdString?NoThe cus_user_id the registration was made for. Default nil (current one)

Throws: TSSessionError.notInitialized, TSSessionError.keychainError

Example — using parsed values:

Swift
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:

Swift
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:

Swift
@MainActor
func reportRegistrationFailed(statusCode: Int?, cusUserId: String? = nil) async throws

Parameters:

ParameterTypeRequiredDescription
statusCodeInt?YesHTTP status your backend answered with; nil when there was no answer
cusUserIdString?NoThe cus_user_id of the .required request that failed. Default nil (the current cus_user_id)

Throws: TSSessionError.notInitialized, TSSessionError.keychainError

Example:

Swift
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:

Swift
@MainActor
func startCollect() async throws

Must be called on @MainActor (main thread).

Throws: TSSessionError.notInitialized, TSSessionError.deviceNotRegistered, TSSessionError.challengeExpired, TSSessionError.invalidChallengeResponse, TSSessionError.networkError

Example:

Swift
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:

Swift
@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:

Swift
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:

Swift
func updateScreenTransition(screenName: String)

Parameters:

ParameterTypeRequiredDescription
screenNameStringYesName of the screen being navigated to

Example:

Swift
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):

Swift
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 = true are 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:

Swift
@MainActor func startKeyboardTracking(textField: UITextField)
@MainActor func startKeyboardTracking(textView: UITextView)

Parameters:

ParameterTypeRequiredDescription
textFieldUITextFieldYesText field to track
textViewUITextViewYesText view to track

Example:

Swift
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:

Swift
func stopKeyboardTracking(textField: UITextField)
func stopKeyboardTracking(textView: UITextView)

Parameters:

ParameterTypeRequiredDescription
textFieldUITextFieldYesText field to stop tracking
textViewUITextViewYesText view to stop tracking

Example:

Swift
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:

Swift
func addKeyboardTrackingData(_ data: TSTypingData)

Parameters:

ParameterTypeRequiredDescription
dataTSTypingDataYesKeyboard event data

TSTypingData fields:

FieldTypeRequiredDescription
timestampInt64YesEvent timestamp in milliseconds since epoch
textLengthInt?NoCurrent text field length
dataString?NoCharacter value (only if trackRawText is true)
typeTSKeyboardDataTypeYesType of keystroke
viewIdStringYesIdentifier for the input field

TSKeyboardDataType values: lowerText, upperText, number, spacing, specialCharacters, delete, cursorMoving, unknown, mixed

Example:

Swift
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:

Swift
func trackTextChange(oldText: String, newText: String, viewId: String)

Parameters:

ParameterTypeRequiredDescription
oldTextStringYesText value before the change
newTextStringYesText value after the change
viewIdStringYesStable identifier for the input field

Example — SwiftUI (iOS 14–16):

Swift
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+):

Swift
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:

Swift
func trackTouchEvent(event: TSPointerEventDto)

Parameters:

ParameterTypeRequiredDescription
eventTSPointerEventDtoYesTouch event data

TSPointerEventDto fields:

FieldTypeDescription
actionNameString?Action name: "down", "move", "up"
actionMaskedInt?Action constant
pointerIdInt?Pointer identifier
globalXDouble?Screen X coordinate
globalYDouble?Screen Y coordinate

Example:

Swift
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:

Swift
@MainActor
func finalizeCollect() async throws -> TSFinalizeCollectResult

Must be called on @MainActor (main thread).

Returns: TSFinalizeCollectResult

FieldTypeDescription
challengeIdStringChallenge ID — pass to backend for scoring
nonceStringSession nonce
lastSeqIntLast uploaded batch sequence number
batchIdString?Last batch identifier (nil if no batches uploaded)
droppedBatchCountIntNumber of event batches dropped without reaching the server (data loss). Defaults to 0; 0 on a clean finalize
lastUploadErrorString?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:

Swift
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:

Swift
@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()
Asyncasync throwsthrows (sync)
Flush dataYesNo
Drain upload queueYes (waits)No
Data loss riskNonePossible
Use caseNormal completionApp backgrounding

Example:

Swift
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:

Swift
@MainActor
func clearUser() async throws

Throws: TSSessionError.notInitialized, TSSessionError.keychainError

Example:

Swift
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:

Swift
@MainActor
func clearDeviceIdentity() async throws

Throws: TSSessionError.notInitialized, TSSessionError.keychainError

Example:

Swift
do {
    try await TSBehavioralSDK.shared.clearDeviceIdentity()
    print("Device identity cleared")
    // Must call initialize() again before using SDK
} catch {
    print("Clear error: \(error)")
}
Deprecated synchronous form
`func clearDeviceIdentity() throws` is deprecated. It throws `notInitialized` immediately, but otherwise returns before the identity is cleared and cannot report a Keychain failure. Use `try await clearDeviceIdentity()`.

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:

Swift
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:

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

ErrornameCondition
notInitializednotInitializedSDK not initialized. Call initialize(config:) first
invalidConfiguration(field:reason:)invalidConfigurationA TSBehavioralConfig value is blank or unusable (baseUrl, bootstrapKeyId, bootstrapPublicKey, challengeTtlMillis). Thrown by initialize(config:)
deviceNotRegistereddeviceNotRegisteredServer-authoritative: the server reports the device is not registered. Call markDeviceRegistered() after registering with your backend
sslPinningError(Error)sslPinningErrorTLS/SSL pinning validation failed — the server certificate did not match a pinned certificate
challengeExpiredchallengeExpiredServer challenge expired. Retry startCollect()
invalidChallengeResponseinvalidChallengeResponseInvalid or empty challenge from server
notStartednotStartedNo active collection session. Call startCollect() first
keychainError(Error)keychainErrorKeychain read/write failed
cryptoError(Error)cryptoErrorCryptographic operation failed
serializationError(Error)serializationErrorFailed to encode/decode a request or response payload
networkError(Error)networkErrorNetwork request failed. The wrapped Error is an ApiError (see below)
unknownError(Error)unknownErrorAn 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).

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

Swift
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).

FieldTypeSourceDescription
device_idStringrequest.deviceIdSDK-generated device identifier
key_pair_idStringrequest.bootstrapKeyIdBootstrap key pair identifier
device_public_keyStringrequest.encryptedDevicePublicKeyPayloadAES-GCM sealed device public key (base64)
encrypted_secret_keyStringrequest.encryptedSecretKeyRSA-OAEP encrypted AES secret key (base64)
cus_user_idStringrequest.cusUserIdCustomer user identifier
protocolString—Always "encrypted"
JSON
{
  "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

JSON
{
  "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:

HeaderValue
AuthorizationTV {accessKeyId}:{signature}
X-TV-TimestampISO 8601 UTC timestamp

Swift Example

Swift
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

Swift
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()