> ## Documentation Index
> Fetch the complete documentation index at: https://usefoil.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# iOS SDK

> Add the Foil iOS SDK to your Swift app with Swift Package Manager, configure it at launch, and hand sealed sessions to your backend for verification.

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.

```swift theme={"dark"}
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:

```swift theme={"dark"}
// 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.

```swift theme={"dark"}
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`

```swift theme={"dark"}
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
}
```

| Field                     | Default        | Description                                                                                                                     |
| ------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `publishableKey`          | —              | Required. Must start with `pk_live_` or `pk_test_`. Secret keys (`sk_*`) are rejected at configure-time.                        |
| `enableBehavioralSignals` | `true`         | Zero-config native behavioral capture: app lifecycle, navigation, input, scroll, and motion. Touch-stroke observers are opt-in. |
| `enableHiddenWebView`     | `true`         | Enables the `attach(to:)` handoff so an in-app `WKWebView` shares this session.                                                 |
| `enableCloudIdentifier`   | `false`        | Opt-in iCloud KVS continuity hint. Requires the iCloud Key-Value Store entitlement on your app to survive reinstall.            |
| `enableAutoAttachTouches` | `false`        | Automatically attaches touch observation to app windows. Leave off if you call `observeTouches(on:contextId:)` manually.        |
| `apiEndpoint`             | production API | Advanced 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.

```swift theme={"dark"}
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:

```swift theme={"dark"}
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.

```swift theme={"dark"}
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.

```swift theme={"dark"}
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.

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

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

```swift theme={"dark"}
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

| Method                                                               | Description                                                                                                             |
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `FoilClient.shared.configure(_:)`                                    | Validates the publishable key and starts the runtime. Throws `FoilError` on invalid input.                              |
| `FoilClient.shared.getSession() async throws -> SessionHandoff`      | Posts the snapshot, performs a bounded best-effort behavioral flush, and returns a sealed handoff.                      |
| `FoilClient.shared.waitForFingerprint() async throws`                | Resolves 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 -> DispatchHealthSnapshot` | Per-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.

```swift theme={"dark"}
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
}
```

| Code                             | Retryable | When it happens                                                                                         |
| -------------------------------- | --------- | ------------------------------------------------------------------------------------------------------- |
| `config.invalid_publishable_key` | no        | `publishableKey` is missing, uses the `sk_*` secret prefix, or doesn't match `pk_live_*` / `pk_test_*`. |
| `client.not_configured`          | no        | A client method was called before `configure(_:)`.                                                      |
| `session.create_failed`          | varies    | Session handshake with the Foil API failed.                                                             |
| `session.handshake_expired`      | no        | The server expired the session. Call `configure(_:)` again to mint a new one.                           |
| `session.invalid_or_expired`     | no        | The active session is invalid or expired.                                                               |
| `transport.batch_post_failed`    | varies    | A signal batch upload failed.                                                                           |
| `transport.rate_limited`         | yes       | HTTP 429 from the API. Back off and retry.                                                              |
| `transport.upgrade_required`     | no        | HTTP 410/426. The SDK version is too old — upgrade the app.                                             |
| `transport.network`              | yes       | DNS, TLS, or connectivity failure.                                                                      |
| `crypto.failure`                 | no        | A crypto primitive failed. Usually a platform issue worth reporting.                                    |
| `internal`                       | no        | Unexpected 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.

```swift theme={"dark"}
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](/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 client** — `FoilClient.shared` is a process-wide singleton.

## What's next

<CardGroup cols={2}>
  <Card title="Server verification" icon="shield-check" href="/server-verification">
    Verify the sealed token on your backend
  </Card>

  <Card title="Browser SDK" icon="monitor" href="/browser-sdk">
    Equivalent guide for the web surface
  </Card>

  <Card title="Android SDK" icon="https://mintcdn.com/abxy/GMpwX6jvN2QwWvP1/images/android.svg?fit=max&auto=format&n=GMpwX6jvN2QwWvP1&q=85&s=83f0b17dba056171a7bfb26b123edaea" href="/android-sdk" width="24" height="24" data-path="images/android.svg">
    Native Android integration
  </Card>

  <Card title="Going to production" icon="rocket" href="/going-to-production">
    Rollout checklist and monitoring
  </Card>
</CardGroup>
