# iOS SDK

# iOS SDK

The Frak iOS SDK brings referral tracking, rewards, and the sharing sheet to a native iOS app. It is a Swift package with zero third-party dependencies, split in two products so an app that only needs tracking never links a web view.

## Requirements

| Item | Value |
| --- | --- |
| Minimum iOS | 15 |
| Minimum Xcode | 16 (the package declares Swift 6 language mode) |
| Dependencies | None |
| License | Apache-2.0 |

## Install

In Xcode: **File → Add Package Dependencies**, then enter `https://github.com/frak-id/frak-ios-sdk`.

Or in a `Package.swift`:

```swift title="Package.swift"
dependencies: [
    .package(url: "https://github.com/frak-id/frak-ios-sdk.git", exact: "1.0.0-beta.1")
],
targets: [
    .target(name: "YourApp", dependencies: [
        .product(name: "FrakSDK", package: "frak-ios-sdk"),
        // Only if you show the sharing sheet
        .product(name: "FrakSDKUI", package: "frak-ios-sdk"),
    ])
]
```

| Product | Contents |
| --- | --- |
| `FrakSDK` | Identity, config, rewards, interaction tracking, sharing links, app links. No UI, no web view. |
| `FrakSDKUI` | Adds the sharing sheet, a `WKWebView` inside a SwiftUI sheet. Depends on `FrakSDK`. |

The dependency only runs one way, so taking `FrakSDK` alone links no web view.

`FrakSDKVersion.current` returns the version the SDK reports on every request, which is useful in a bug report. It is the one public type absent from the [API reference](/developers/references/ios/): Swift's symbol graph extractor drops any declaration whose name starts with the module name followed by `Version`, since that is the shape of the globals Clang generates for a framework.

**The package repository is generated:** `frak-id/frak-ios-sdk` is a mirror: its `main` branch is force-pushed from `frak-id/wallet` on each release. Check the repository releases for the current tag, and open issues and pull requests on [`frak-id/wallet`](https://github.com/frak-id/wallet) rather than on the mirror. Distribution is source through SwiftPM; there is no XCFramework.

## Declare the wallet schemes

Add this to your `Info.plist` before anything else. It is what lets your app talk to the Frak wallet: without it, iOS answers "not installed" to every probe, so `isFrakAppInstalled()` returns false, the sharing sheet's install detection never fires, and the handoff to the wallet silently degrades to the App Store every time.

```xml title="Info.plist"
<key>LSApplicationQueriesSchemes</key>
<array>
    <string>frakwallet</string>
    <string>frakwallet-dev</string>
</array>
```

List both: `frakwallet` is the production wallet and `frakwallet-dev` is the one you test against on a dev build. Declaring only production makes a locally built dev wallet undetectable, which is exactly the build a first integration runs against.

You also declare the URL scheme (or Universal Link) your own app receives referral links on. See [Referral deep links](#referral-deep-links) below.

## Initialize

Call `Frak.initialize` once, at app startup:

```swift
import FrakSDK

@main
struct YourApp: App {
    init() {
        Frak.initialize(
            FrakConfig(
                merchantId: "your-merchant-id",
                metadata: FrakMetadata(
                    name: "Your Store",
                    currency: .eur,
                    homepageLink: "https://your-store.com"
                ),
                logLevel: .info
            )
        )
    }

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

### Configuration options

| Option | Default | Meaning |
| --- | --- | --- |
| `merchantId` | `nil` | Your merchant ID from the [business dashboard](https://business.frak.id/). When nil, the merchant is resolved from `bundleId`. |
| `bundleId` | `nil` | Falls back to `Bundle.main.bundleIdentifier`. |
| `metadata` | empty | `name`, `currency`, `lang`, `logoURL`, `homepageLink`. |
| `deepLink` | `.manual` | `.manual` or `.disabled`. iOS has no automatic mode, see below. |
| `trackingEnabled` | `true` | A hard floor. When false, no anonymous ID is ever minted. |
| `logLevel` | `.none` | `.none`, `.error`, `.warn`, `.info`, `.debug`. |
| `logSink` | `nil` | A `FrakLogSink` that replaces the default logger. |

`homepageLink` is the last fallback of the share link chain: without it, a store-wide share with no product and no explicit link has nothing to point at.

## The client

`Frak.client` is throwing-synchronous, `Frak.clientOrNull` returns an optional instead. Every namespace member on the client is `async`.

```swift
private func client() -> FrakClient? { try? Frak.client }

let reward = await client()?.rewards.best(targetInteraction: "purchase")
```

The client exposes `environment`, `anonymousId`, `resetAnonymousId()`, `setTrackingEnabled(_:)`, `isTrackingEnabled()`, and five namespaces: `config`, `rewards`, `sharing`, `tracking`, `appLink`.

The snippets below assume you already hold a client, for example with `let client = try Frak.client`.

## Tracking

```swift
// A confirmed order, on your order confirmation screen
let result = await client.tracking.purchase(
    customerId: "cust_123",
    orderId: "order_456",
    token: "a-unique-order-token"
)

switch result {
case .success: print("order tracked")
case .failure(let error): print(error.localizedDescription)
}

// Any other interaction
await client.tracking.track(.custom("added_to_cart"))
```

Both calls return a `Result` rather than throwing, and both succeed once the event is **durable**, not once it is delivered. Events are written to a queue on disk and drained with retries, so an offline device still reports later.

`Interaction` is built through static factories:

```swift
Interaction.custom("checkout")
Interaction.custom("checkout", data: ["plan": "pro"])
Interaction.sharing()
Interaction.arrival(referrerWallet: nil, referrerClientId: nil, referrerMerchantId: nil, referralTimestamp: nil)
```

You rarely build `arrival` yourself: `appLink.handleReferral` does it for you.

**Purchases still need a backend confirmation:** Calling `tracking.purchase` registers the order. The reward is only paid once your server confirms the sale with a signed webhook. The `token` is your own checkout token, not a StoreKit receipt. See [Validate purchases from your backend](/guides/platforms/custom/backend/).

## Rewards

```swift
let campaigns = try await client.rewards.campaigns()

let best = try await client.rewards.best(
    targetInteraction: "purchase",
    products: visibleProducts.map { ProductDetails(productId: $0.id, name: $0.title) }
)
```

Call `best` **once per screen for the whole visible product set**, not once per row: a single `BestReward` cannot be mapped back onto per-item rows, and the cache is keyed on the encoded product list. Both calls accept `forceRefresh: true` to skip the cache.

## Configuration resolution

```swift
let resolved = try await client.config.resolve()

// Or observe changes
for await config in await client.config.updates {
    // react to a refreshed merchant config
}
```

`resolve` is stale-while-revalidate, `current` returns the last resolved config without a call, and `updates` is a multicast stream that replays the latest value. See [Backend-driven configuration](/developers/concepts/backend-configuration/).

## Sharing

### Build a link yourself

```swift
let link = try await client.sharing.buildLink(
    SharingRequest(
        products: [
            SharingProduct(
                title: product.title,
                link: product.link,
                imageURL: product.imageURL,
                utmContent: product.id,
                details: ProductDetails(productId: product.id, name: product.title)
            )
        ],
        targetInteraction: "purchase",
        placement: "product-page"
    )
)
```

`buildLink` returns `nil` only when there is nothing to link to (no request link, no product link, and no homepage fallback). It throws a `FrakError` when a link should have been buildable but was not, for example when tracking is disabled.

`AttributionParams` (`utmSource`, `utmMedium`, `utmCampaign`, `utmContent`, `utmTerm`, `via`, `ref`) can be passed per call and merges over your merchant-level defaults.

### The sharing sheet

`FrakSDKUI` adds a single view modifier:

```swift
import FrakSDKUI

struct ProductView: View {
    @State private var isSharing = false

    var body: some View {
        Button("Share and earn") { isSharing = true }
            .frakSharingSheet(isPresented: $isSharing, request: request) { result in
                switch result {
                case .shared(let link): print("shared \(link)")
                case .copied(let link): print("copied \(link)")
                case .installStarted: break   // informational only
                case .walletOpened: break     // the wallet was installed and opened
                case .dismissed: break
                case .failed(let error): print(error.localizedDescription)
                }
            }
    }
}
```

**One sheet per screen, not per row:** Attaching the modifier always warms a pooled `WKWebView`. Hoist it onto a screen-level view: one modifier per list row is one web engine per row.

The callback fires once per presentation, with the most significant outcome. Ranked lowest to highest: `failed`, `dismissed`, `shared` and `copied`, `installStarted`, `walletOpened`. So a user who installs the wallet and then swipes the sheet away still reports the install, not the dismissal.

### Tuning the sheet

```swift
.frakSharingSheet(
    isPresented: $isSharing,
    request: request,
    configuration: FrakSharingConfiguration(
        heightFraction: 0.9,
        install: .overlay(.init(position: .bottomRaised))
    )
) { result in
    // handle SharingResult
}
```

| Option | Default | Meaning |
| --- | --- | --- |
| `heightFraction` | `0.85` | Sheet height, clamped to `0.3...1.0`. |
| `install` | `.storeProductPage` | `.storeProductPage` raises a modal `SKStoreProductViewController`; `.overlay` shows an `SKOverlay` banner that does not cover the sheet. |
| `detectInstall` | `true` | Polls for the wallet becoming installable while the store surface is up, then hands off and reports `.walletOpened`. |

Install detection relies on the same `LSApplicationQueriesSchemes` entry that `isFrakAppInstalled()` needs. Without it, neither works.

## Referral deep links

iOS offers no automatic mode: nothing lets a library install itself in front of your app's own URL routing. Wire it yourself:

```swift
.onOpenURL { url in
    Task { await Frak.clientOrNull?.appLink.handleReferral(url) }
}
```

`handleReferral` returns whether the URL carried a Frak referral context. It is not a "stop routing" signal, so keep navigating either way.

The rest of the namespace:

```swift
await client.appLink.isFrakAppInstalled()
await client.appLink.openFrakApp()          // .openedApp, .openedStore, or .failed
try await client.appLink.installPageURL(returnScheme: "yourapp", sessionId: sessionId)
```

`Frak.parseReferralLink(_:)` is static and pure, so you can decode a link before the SDK is initialized.

### Info.plist

On top of the [wallet schemes](#declare-the-wallet-schemes), declare the scheme your app receives referral links on:

```xml title="Info.plist"
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLName</key>
        <string>com.your-company.your-app</string>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>yourapp</string>
        </array>
    </dict>
</array>
```

To receive `https://` referral links instead of a custom scheme, add the Associated Domains capability and publish an `apple-app-site-association` file for your domain. That part is standard Universal Links setup and the SDK does not do it for you.

## Consent and privacy

The SDK ships no consent UI. Wire your consent flow to:

```swift
await client.setTrackingEnabled(false)  // stops tracking and purges anything queued
await client.resetAnonymousId()         // rotates the local identity
```

Both products ship a `PrivacyInfo.xcprivacy`. It declares three collected data types, all linked to the user, none used for tracking:

| Data type | What it covers |
| --- | --- |
| User ID | The anonymous ID and the `customerId` you pass to `tracking.purchase`. |
| Purchase history | `customerId`, `orderId`, and the checkout token. |
| Product interaction | Arrival, sharing, and custom interactions. |

`NSPrivacyTracking` is `false`: no ad network is in the SDK path.

**Keep the SDK current:** Since 1 May 2024, an SDK that uses a required-reason API without declaring it makes **your** App Store upload fail with ITMS-91053. That rejection lands on your build, not ours, so keep the package up to date.

**Custom interaction data is yours to declare:** `Interaction.custom(_:data:)` takes an arbitrary `[String: String]` that the SDK persists and transmits. Put an email address or a user ID in there and your own binary's privacy declarations need to say so.

## Next steps

[API reference](/developers/references/ios/)
  [Android SDK](/developers/integration/android/)
  [Backend validation](/guides/platforms/custom/backend/)
  [Interactions](/developers/concepts/interactions/)
  [Mobile setup guide](/guides/platforms/custom/mobile/)