Skip to main content
The iOS SDK mirrors the browser SDK: configure it on startup, call getSession() when the user performs a sensitive action, and POST the sealed token to your backend for verification.
import Foil

try FoilClient.shared.configure(
    FoilConfiguration(publishableKey: "pk_live_...")
)

// At action time (signup, login, checkout)
let handoff = try await FoilClient.shared.getSession()

Requirements

  • iOS 14+ for the public binary SwiftPM package
  • Swift 5.9+
  • Xcode 15+
  • A Foil publishable key, starting with pk_live_ or pk_test_

Install

Add Foil with Swift Package Manager:
// Package.swift
dependencies: [
    .package(url: "https://github.com/abxy-labs/foil-ios", from: "1.1.0"),
],
Then add the Foil product to your app target. In Xcode, choose File > Add Package Dependencies…, enter https://github.com/abxy-labs/foil-ios, choose version 1.1.0 or newer, and add the Foil product to the target that configures the SDK.

Configure on startup

Configure once at app launch — typically from your App initializer — so signal collection is running by the time the first screen appears.
import SwiftUI
import Foil

@main
struct MyApp: App {
    init() {
        do {
            try FoilClient.shared.configure(
                FoilConfiguration(publishableKey: "pk_live_your_publishable_key")
            )
        } catch {
            // Reporting only — do not crash on Foil misconfiguration.
            print("Foil failed to configure:", error)
        }
    }

    var body: some Scene {
        WindowGroup { ContentView() }
    }
}

FoilConfiguration

public struct FoilConfiguration: Sendable {
    public static let defaultAPIEndpoint: URL
    public let publishableKey: String
    public var enableBehavioralSignals: Bool
    public var enableHiddenWebView: Bool
    public var enableCloudIdentifier: Bool
    public var enableAutoAttachTouches: Bool
    public var apiEndpoint: URL
}
FieldDefaultDescription
publishableKeyRequired. Must start with pk_live_ or pk_test_. Secret keys (sk_*) are rejected at configure-time.
enableBehavioralSignalstrueZero-config native behavioral capture: app lifecycle, navigation, input, scroll, and motion. Touch-stroke observers are opt-in.
enableHiddenWebViewtrueEnables the attach(to:) handoff so an in-app WKWebView shares this session.
enableCloudIdentifierfalseOpt-in iCloud KVS continuity hint. Requires the iCloud Key-Value Store entitlement on your app to survive reinstall.
enableAutoAttachTouchesfalseAutomatically attaches touch observation to app windows. Leave off if you call observeTouches(on:contextId:) manually.
apiEndpointproduction APIAdvanced override for local development or private deployments.
Runtime integrity and anti-tamper collection is always enabled. It is part of the baseline SDK behavior, not an integration option.

Get a session at action time

Call getSession() from an async context right before the sensitive action — not on app launch.
struct CheckoutView: View {
    func submit() async {
        do {
            let handoff = try await FoilClient.shared.getSession()

            var request = URLRequest(url: URL(string: "https://api.example.com/checkout")!)
            request.httpMethod = "POST"
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            request.httpBody = try JSONEncoder().encode(CheckoutRequest(
                items: cart,
                foil: .init(
                    sessionId: handoff.sessionId,
                    sealedToken: handoff.sealedToken
                )
            ))

            _ = try await URLSession.shared.data(for: request)
        } catch {
            // Degrade gracefully — see Error handling below.
        }
    }
}
SessionHandoff is a small value type:
public struct SessionHandoff: Sendable, Equatable {
    public let sessionId: String
    public let sealedToken: String
}
  • sessionId is stable for the life of the client.
  • getSession() posts the native snapshot, performs a bounded best-effort behavioral flush, and returns the handoff your backend should verify.
  • The SDK never surfaces verdicts, scores, or visitor IDs to the device — verify on your server.

Behavioral capture

When enableBehavioralSignals is true, the SDK starts zero-config UIKit capture automatically. It records the native equivalents of the browser’s behavioral events: app lifecycle, hashed screen navigation, viewport and scroll, form focus and input, selection and clipboard, and motion. No raw form text, raw placeholders, or raw field identifiers are sent. Field identity is hashed, and geometry is rounded to the field’s window coordinates so the dashboard can show timing and replay context without collecting user-entered values. Touch-stroke dynamics are still opt-in per screen for higher-fidelity gesture data. Call observeTouches(on:) once the view is mounted.
class CheckoutViewController: UIViewController {
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        FoilClient.shared.observeTouches(on: view, contextId: "checkout")
    }
}
The observer attaches a non-consuming gesture recognizer, so your existing gestures keep working unchanged. In SwiftUI, reach for a UIViewRepresentable wrapper to expose the underlying UIView.

WebView correlation

If your app hosts Foil-protected web content in a WKWebView, attach it so the web SDK reuses the native session.
import WebKit

let webView = WKWebView(frame: .zero)
FoilClient.shared.attach(to: webView)
webView.load(URLRequest(url: URL(string: "https://your-app.example.com/signup")!))
The native bridge exposes the session handoff as window.__FOIL_NATIVE__, which the browser SDK picks up when loaded inside the WebView.

Native identity and attestation

Native visitor continuity is resolved server-side from the SDK’s encrypted observations. install_id tracks the app install, device_id tracks the device continuity layer, and native visitor_id represents the same device within your organization. None of those identifiers are exposed to the app. Apple App Attest is optional and positive-only by default. When App Attest is supported, Foil can use SDK-managed App Attest keys to strengthen native identity. When it is unsupported, unavailable, unconfigured, or fails verification, Foil falls back to the other native continuity signals unless your organization explicitly enables strict attestation.
To verify App Attest in production, add your iOS app identity in the Foil dashboard. Baseline native continuity still works without this setup, but App Attest will not upgrade identity confidence until the app identity is configured.

Diagnostics

dispatchHealth() returns per-channel counters of successful and failed batch posts since the last configure(_:). Use it to confirm signals are reaching the server when you don’t have server-side visibility — for example, to spot a TLS-pinning, proxy, or quota issue from the client.
let health = await FoilClient.shared.dispatchHealth()
for (channel, stats) in health.channels {
    print(channel,
          "ok:", stats.consecutiveSuccesses,
          "fail:", stats.consecutiveFailures)
}
consecutiveFailures rising without a matching rise in consecutiveSuccesses typically means a sustained network outage or a server-side rejection that retries can’t recover from. Counters reset on configure(_:), destroy(), or resetLocalState().

API reference

MethodDescription
FoilClient.shared.configure(_:)Validates the publishable key and starts the runtime. Throws FoilError on invalid input.
FoilClient.shared.getSession() async throws -> SessionHandoffPosts the snapshot, performs a bounded best-effort behavioral flush, and returns a sealed handoff.
FoilClient.shared.waitForFingerprint() async throwsResolves when the durable visitor fingerprint is ready. Optional — getSession() works without it.
FoilClient.shared.observeTouches(on:contextId:)Attaches a non-consuming gesture recognizer to the view. Call once per screen.
FoilClient.shared.attach(to:)Shares the current session with a hosted WKWebView.
FoilClient.shared.dispatchHealth() async -> DispatchHealthSnapshotPer-channel batch-post counters since the last configure(_:). Useful for confirming telemetry is reaching the server.
FoilClient.shared.resetLocalState()Stops the runtime and clears the durable session and outbox state. Long-lived install/device anchors are preserved.
FoilClient.shared.destroy()Stops timers and releases resources. Rarely needed outside tests.

Error handling

FoilError is a Sendable struct carrying a stable code, a human-readable message, an optional underlying error, and a retryable flag.
public struct FoilError: Error, Sendable {
    public enum Code: String, Sendable {
        case invalidPublishableKey = "config.invalid_publishable_key"
        case notConfigured         = "client.not_configured"
        case sessionCreateFailed   = "session.create_failed"
        case batchPostFailed       = "transport.batch_post_failed"
        case handshakeExpired      = "session.handshake_expired"
        case sessionInvalidOrExpired = "session.invalid_or_expired"
        case rateLimited           = "transport.rate_limited"
        case serverUpgradeRequired = "transport.upgrade_required"
        case network               = "transport.network"
        case cryptoFailure         = "crypto.failure"
        case internalError         = "internal"
    }

    public let code: Code
    public let message: String
    public let underlying: Error?
    public let retryable: Bool
}
CodeRetryableWhen it happens
config.invalid_publishable_keynopublishableKey is missing, uses the sk_* secret prefix, or doesn’t match pk_live_* / pk_test_*.
client.not_configurednoA client method was called before configure(_:).
session.create_failedvariesSession handshake with the Foil API failed.
session.handshake_expirednoThe server expired the session. Call configure(_:) again to mint a new one.
session.invalid_or_expirednoThe active session is invalid or expired.
transport.batch_post_failedvariesA signal batch upload failed.
transport.rate_limitedyesHTTP 429 from the API. Back off and retry.
transport.upgrade_requirednoHTTP 410/426. The SDK version is too old — upgrade the app.
transport.networkyesDNS, TLS, or connectivity failure.
crypto.failurenoA crypto primitive failed. Usually a platform issue worth reporting.
internalnoUnexpected state. File a support ticket with the message and underlying error.
The code values are stable. Category prefixes (config.*, transport.*) are safe to branch on with a wildcard — new codes within a category keep the same retry semantics.

Fallback policy

If Foil fails, your app should still work. Log the error and continue without a handoff.
func foilHandoff() async -> SessionHandoff? {
    do {
        return try await FoilClient.shared.getSession()
    } catch let err as FoilError where err.retryable {
        return try? await FoilClient.shared.getSession()
    } catch {
        reportError(error)
        return nil
    }
}
See Going to production for guidance on fall-open vs. fall-closed policy.

Best practices

  • Configure once at launch so signals are collecting before any screen opens.
  • Call getSession() late — right before the sensitive action, from an async context.
  • Degrade gracefully — never block the user if the SDK fails to produce a handoff.
  • Reuse the shared clientFoilClient.shared is a process-wide singleton.

What’s next

Server verification

Verify the sealed token on your backend

Browser SDK

Equivalent guide for the web surface
https://mintcdn.com/abxy/GMpwX6jvN2QwWvP1/images/android.svg?fit=max&auto=format&n=GMpwX6jvN2QwWvP1&q=85&s=83f0b17dba056171a7bfb26b123edaea

Android SDK

Native Android integration

Going to production

Rollout checklist and monitoring