The iOS SDK is written in Swift with no third-party dependencies. It collects device signals within a time budget, submits them with your public key, and returns the server's answer with local verdicts. It never shows a permission prompt.
Requirements
- iOS 13 or newer (also tvOS 13 and Mac Catalyst 13).
- Xcode 16 or newer.
- A public key.
Install
.package(url: "https://github.com/fingerly-io/sdk-ios.git", from: "0.1.0")
In Xcode, add the package from File > Add Package Dependencies and link the Fingerly product to your app target.
Identify a device
import Fingerly
// consent: .granted only once you have consent (or another lawful basis)
let fingerly = try await Fingerly.load(apiKey: "fly_pk_production_…", consent: .granted)
func signIn(email: String, password: String) async throws {
let result = try await fingerly.identify(tag: "sign-in")
try await api.signIn(email: email, password: password, requestId: result.requestId)
}
Load once and keep the client for the life of the app. identify runs on Swift concurrency and never touches the main thread. consent defaults to .pending, which collects nothing: load with .pending and call setConsent(.granted) from your consent tool, or pass .granted as here once you have consent. See consent tools.
Send requestId to your backend with the action, and decide there after reading the stored event with a secret key. See server-side verification.
Configuration
Fingerly.load
apiKeyStringrequiredYour public key. Its prefix names the environment it submits to.endpointStringDefault""A custom API origin, to which/api/v1/ingestis appended. Empty useshttps://api.fingerly.io. Proxy integrations serve the browser SDK, not apps.scheduleScheduleOptionsDefaultbudgetMs 1200The collection budget:budgetMs,defaultSourceTimeoutMs,concurrencyandtiers.consentConsentStateDefault.pending.granted,.pendingor.denied. Until it is.granted,identifyandcollectread and send nothing and throwConsentError. Change it withsetConsent(_:). See consent.
identify
tagString?Your own reference for this identification, such ascheckout:8412.tiers[SourceTier]DefaultallWhich collection tiers to run:.fast,.deferred, or both.submitBoolDefaulttrueSetfalseto collect and compute verdicts without sending anything.
let fingerly = try await Fingerly.load(
apiKey: "fly_pk_production_…",
consent: .granted,
schedule: ScheduleOptions(budgetMs: 800, concurrency: 4)
)
// A screen that cannot wait: only the fast tier.
let result = try await fingerly.identify(tiers: [.fast])
The result
IdentifyResult
requestIdStringIdentifies this identification. Send it to your server with the action it protects.visitorIdStringThe stable identifier the server resolved for this device.visitorIsNewBoolWhether your organization is seeing this visitor for the first time.visitorConfidenceIntHow sure the identification is, from 0 to 100.identifiableBoolfalsewhen the device gave too little to identify anyone.duplicateBooltruewhen the server had already answered this request.stateStringenriched, orunavailablewhen the network lookup could not run.suspectScoreInt?The server's weighted score.nilwhen nothing was scored, which is not the same as0.suspectLevelString?low,mediumorhigh.triggers[IdentifyTrigger]The signals the server scored:signal,group,weightandconfidence.verdictsVerdictsLocal, advisory verdicts computed on the device.reportSignalReportThe report that was sent.
Verdicts
| Verdict | What it means |
|---|---|
jailbreak | The device is jailbroken. |
simulator | The app is running in the iOS Simulator. |
instrumentation | An instrumentation toolkit is attached to the app. |
mitm | Something is intercepting the app's encrypted traffic. |
automation | A debugger or a UI test runner is driving the app. |
tampering | The app's code has been hooked or modified at runtime. |
farm | The device looks mass-provisioned or freshly reset. |
Each verdict has value, confidence (.low, .medium, .high) and reasons. They are advisory. The server scores the same evidence with your risk weights.
if result.verdicts.jailbreak.value, result.verdicts.jailbreak.confidence == .high {
// add friction here, and let your server make the final decision
}
The install ID
The SDK keeps one small record in the Keychain: a random install ID, when it was first seen and how many times the app has launched. It is kept only on this device, so it is never in a backup, and it survives the app being deleted, so a reinstall on the same device keeps its visitor ID. It is read and written only while consent is granted.
The server recognises the device by this install ID and nothing else. The device signals are used for the verdicts and the score, never to identify a device or a user.
Deleting the stored identity
fingerly.setConsent(.denied) // if nothing should be collected afterwards
try Fingerly.deleteStoredIdentity()
Deletes the install record, for erasure requests and account deletion. Because the record survives the app being deleted, this is the only way to remove it. It needs no client and no consent, sends nothing, and deleting a record that does not exist is not an error. A Keychain refusal throws StoredIdentityError with the Keychain status. The next identification made with consent starts a new install ID that nothing links to the old one. See account deletion and erasure.
App Store
- The SDK ships a privacy manifest,
PrivacyInfo.xcprivacy, in the Swift package and in the pod'sFingerly_Privacyresource bundle. Its only required-reason API is file timestamps, reasonC617.1, for the age of your app's own container. - It does not track, so it needs no App Tracking Transparency prompt.
- Do not ship it in Kids Category apps, and do not use the browser SDK inside a
WKWebViewin an iOS app; use this SDK instead. - See App Store and Google Play privacy for the App Privacy answers.
Optional: jailbreak app checks
No Info.plist usage description is required. To let the SDK check for well-known jailbreak apps, declare their URL schemes. The SDK only queries the schemes your app declares.
<key>LSApplicationQueriesSchemes</key>
<array>
<string>cydia</string>
<string>sileo</string>
<string>zbra</string>
<string>filza</string>
</array>
Errors
identify and collect throw ConsentError when consent is not .granted. identify throws a TransportError with message, status and retryable. The SDK already retries network failures, 429 and 5xx up to three attempts with one idempotency key. 401 means the key is wrong; 402 means your organization is not accepting traffic.
do {
let result = try await fingerly.identify(tag: "checkout")
} catch let error as TransportError where error.status == 401 {
// the key is wrong or revoked
} catch {
// proceed, and let your server treat the missing request ID as missing evidence
}
Objective-C
FingerlyBridge exposes configure(apiKey:endpoint:platform:), identify(tag:submit:completion:), collect(completion:) and deleteStoredIdentity() to Objective-C. Errors use the domain io.fingerly.sdk with the HTTP status as the code. deleteStoredIdentity() returns nil on success, or an error with code -3 and the Keychain status under userInfo["status"].