# Swift SDK reference

> Source: https://cross-deck.com/docs/swift-sdk/ · Crossdeck developer docs (generated from the published page).

Reference
Current version: 1.7.6
·
~18 min read
·
Updated May 25, 2026

`@cross-deck/swift` is one native SDK that handles all three Crossdeck pillars across Apple platforms: **verified subscriptions and entitlements**, **behavioural analytics**, and **error capture**. Start one `Crossdeck` client at app launch and every screen tap, paywall gate, and uncaught exception flows through the same durable queue, the same idempotent network path, and the same dashboard your web users do. Zero runtime dependencies — built on `Foundation` / `URLSession` / `os.Logger` only.

## TL;DR

- **One Swift Package, three pillars.** One dependency — `@cross-deck/swift` — covers analytics, subscription/entitlement gating, and error capture across iOS / iPadOS / macOS / tvOS / watchOS. No separate Sentry + Mixpanel + RevenueCat install.

- **Bank-grade durability.** `track(...)` returns sync; the event sits in an actor-isolated buffer, persists to `UserDefaults` on every enqueue, and ships in batches with a stable per-batch `Idempotency-Key` reused across retries. A crash mid-flush leaves the pending batch on disk; the next launch rehydrates and re-sends with the same key.

- **Microsecond, receipt-backed access gates.** Entitlement reads are synchronous — safe from a paywall tap handler — but access is owned by the device's **Apple-signed StoreKit receipt**, so a returning subscriber is entitled instantly, offline, before any backend round-trip. For anything a user sees, gate on the three-state `entitlementStatus`, not the `isEntitled` Bool (which reads a still-resolving payer as free). See [Paid access — bank-grade gating](#paid-access) — the authoritative rules; this bullet is only the headline.

- **Built for Apple lifecycle.** Background & foreground transitions auto-trigger a queue persist + flush so a force-quit while suspended can't lose buffered events. `NSException` + manual `captureError(...)` route through one path with breadcrumbs attached.

- **Strict concurrency by construction.** Every piece of mutable state lives behind a Swift actor (`EventQueue`, `Identity`, `EntitlementCache`, `SuperProperties`, `Breadcrumbs`). All cross-actor types are `Sendable`. The SDK compiles clean with `-Xfrontend -enable-experimental-feature StrictConcurrency`.

- **PII scrubbing on by default.** Emails are replaced with ` `, payment-card numbers with ` `, recursively across nested dictionaries and arrays — same vocabulary as the Web, Node, and React Native SDKs. The persisted queue never holds raw PII, so a crash dump of disk state is safe to share with support.

- **Zero runtime dependencies.** Implemented against `Foundation`, `URLSession`, and `os.Logger` only. No OkHttp, no Alamofire, no swift-log — your build never inherits a third-party version conflict from us.

## Install

One Swift Package covers every Apple platform we support. The library target ships as a single module — `import Crossdeck` brings the entire public API into scope. Tested on iOS 13+, iPadOS 13+, macOS 11+, tvOS 13+, and watchOS 7+.

Platform Minimum target Notes

iOS / iPadOS 13.0+ StoreKit purchase rail features require iOS 15+ at runtime; the SDK still compiles + runs on 13/14 for non-purchase consumers (analytics, errors).
macOS 11.0 Big Sur+ Mac Catalyst apps report platform: "macos" .
tvOS 13.0+ Same SDK; no UIDevice-specific code paths required.
watchOS 7.0+ Same SDK; lightweight enough to run inside a complication update.

### Swift Package Manager — Xcode UI

- **File → Add Package Dependencies…**

- Paste the URL into the search field (top-right of the dialog): 

```
https://github.com/Crossdeckhq/crossdeck-swift.git
```

- In the **Dependency Rule** dropdown on the right, select **"Up to Next Major Version"** and enter `1.5.0`. **Do not leave it set to "Branch: main"** — branch tracking auto-pulls every commit including breaking changes when v2.0.0 lands. "Up to Next Major Version" gives you patch + minor updates automatically and lets you choose when to take breaking changes.

- Click **Add Package**. Xcode resolves the package and offers to add the `Crossdeck` library product to your app target — accept.

If your package is already tracking Branch: main , switch it to a tag.

If you added the Crossdeck package before stable tags existed, Xcode auto-anchored your project to `Branch: main`. That works but pulls every new commit silently. **Do not try to fix this via File → Add Package Dependencies…** — that dialog is hard-blocked from changing the rule on an existing dependency (the Dependency Rule dropdown greys out with "already depends on … with rule main" at the bottom). Don't waste time fighting it.

**Path that works — change the rule in place via the project's Package Dependencies tab:**

- In Xcode's file navigator (left sidebar), click your project's top-level entry — the one with the **blue Xcode icon** (the `.xcodeproj` file).

- The main editor now shows project settings. On the LEFT side of that editor are two columns: **PROJECT** and **TARGETS**. Click your project (e.g. **Clippi**) under **PROJECT** — *not* under TARGETS. (Easy to mix up; the rule editor only lives on the project, not the target.)

- Along the top tab bar of the editor, click **Package Dependencies** (next to *Info*, *Build Settings*, *Build Phases*).

- You'll see a table of every package your project depends on. Find `crossdeck-swift`.

- **Double-click the `crossdeck-swift` row.** A small sheet pops up with the Dependency Rule editor — this is the ONLY UI in Xcode that can change a rule on an already-added package.

- In the sheet, change the rule from *Branch* to *Up to Next Major Version*, set the version to `1.5.0`.

- Click **Done**. Xcode re-resolves and rewrites `Package.resolved`.

**If double-click doesn't open the sheet:** select the row, then look for a small edit icon (pencil or chevron) on the row — or right-click → *Modify Package Settings* (exact label varies by Xcode version).

**Bulletproof fallback (no Xcode UI):** Quit Xcode. Open `YourProject.xcodeproj/project.pbxproj` in a text editor. Find the `XCRemoteSwiftPackageReference "crossdeck-swift"` block. Change the `requirement` entry from `branch = main;` to `kind = upToNextMajorVersion; minimumVersion = 1.0.0;`. Save. Re-open Xcode — it picks up the change and re-resolves.

Already installed Crossdeck and a new API won't compile? Update the package.

Xcode pins the exact version it first downloaded in `Package.resolved` and **does not** move forward on its own — even when your Dependency Rule says "Up to Next Major Version." So if you built against an older release and a newly-shipped method (e.g. `appAccountTokenForCurrentIdentity()` or `.crossdeckTap()`) reports *"value of type 'Crossdeck' has no member …"*, you are almost certainly compiling against the old pinned tag — not a bug in the SDK.

**The one-button fix:** **File → Packages → Update to Latest Package Versions**. Xcode re-resolves `crossdeck-swift` to the newest tag your rule allows and rewrites `Package.resolved`. Watch the title bar — it will show *"Checking out crossdeck-swift @ 1.5.1…"* (or newer). After it finishes, build again and the new API resolves.

**If it still resolves to the old tag:** first **File → Packages → Reset Package Caches**, then run Update to Latest again. Nuclear option — quit Xcode and delete `YourProject.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`; Xcode regenerates it from scratch on next open.

### Swift Package Manager — Package.swift

```
dependencies: [
    .package(url: "https://github.com/Crossdeckhq/crossdeck-swift.git", from: "1.0.0"),
],
targets: [
    .target(
        name: "MyApp",
        dependencies: [
            .product(name: "Crossdeck", package: "crossdeck-swift"),
        ]
    ),
]
```

`from: "1.5.0"` is the SwiftPM shorthand for "Up to Next Major Version" — same rule as the Xcode picker. SwiftPM will resolve to the highest 1.x.x available, so future patches/minors land automatically; major bumps require an explicit rule change.

The product name is Crossdeck (no scope).

The *repository* is `crossdeck-swift`, the *package* name (in `Package.swift`) is `Crossdeck`, and the *library product* you import is `Crossdeck`. SwiftPM uses the product name for the `import` statement.

## Quickstart — five lines, full mobile pillar

The fastest path from zero to "events flowing, errors captured, entitlements gated" is one start call in your app delegate or `App`'s `init` — wrapped in `do`/`catch` so a misconfigured key never crashes a customer's launch:

```
import Crossdeck

let cd: Crossdeck?
do {
    cd = try Crossdeck.start(options: CrossdeckOptions(
        appId: "app_ios_xxx",
        publicKey: "cd_pub_live_…",        // or cd_pub_test_… for sandbox
        environment: .production                // MUST match the key prefix — see Initialise
    ))
} catch {
    cd = nil                                  // log + degrade, never crash the host app
}

// Anywhere in your view code (cd? propagates the Optional):
cd?.track("paywall_seen", properties: ["variant": "annual"])

if cd?.isEntitled("pro") == true {
    // sync, cache-read fast. For a USER-FACING paywall/feature gate, read
    // entitlementStatus instead — see "Paid access — bank-grade gating" below.
}
```

That single setup gives you, automatically and without writing any more code:

- Durable batched event delivery — `track()` returns sync; the SDK flushes at `batchSize` (20) or `flushIntervalMs` (5 s), whichever fires first.

- App-background & app-resume `UIApplication` notification observers wired automatically — when the user backgrounds the app, the buffer persists to `UserDefaults` and a best-effort flush runs against the iOS background-execution budget.

- An `anonymousId` minted on first launch, persisted across launches, used as the identity envelope on every event until `identify(...)` is called.

- PII scrubbing on by default — emails and card numbers replaced with ` ` / ` ` tokens before the event ever leaves the device.

- Per-batch `Idempotency-Key` guarantees the server dedups retries — the same payload never lands twice.

- Entitlement reads are synchronous (safe from a tap handler), but access is owned by the device's Apple receipt — a returning subscriber is entitled offline, before the backend cache warms. For any user-facing gate, read the three-state `entitlementStatus`, not the `isEntitled` Bool — see [Paid access — bank-grade gating](#paid-access).

### SwiftUI surfaces need two one-line bolt-ons

The auto-tracking above covers **sessions, errors, queue durability, and identity** with zero per-surface code. **Two dashboard surfaces specifically — Pages and Top Actions / CTA labels — need one modifier per screen and one per important button** on iOS 16+ SwiftUI. This isn't a CrossDeck regression; Apple's Metal text-rendering pipeline and SwiftUI's accessibility-merge model genuinely hide screen names and button labels from any UIView-level runtime introspection (the same wall every analytics SDK hits — Mixpanel, Amplitude, and PostHog all ship the same per-surface modifier pattern).

- **Per destination View → [.crossdeckScreen("Name")](#screen-views)**. Populates the Pages dashboard ("Create Image · 63 views · 14 visitors"). Without it, the SDK's UIKit auto-track has nothing to fire on for pure-SwiftUI screens (UIHostingController is denylisted because tagging it would replay on every navigation push) — Pages shows **zero rows**.

- **Per important CTA → [.crossdeckTap("Name")](#tap-labels)**. Populates Top Actions on the page detail and renders "Clicked 'Name'" on the per-person journey. Without it, modern SwiftUI Button taps still fire `element.clicked` events but with an empty label — the journey reads the useless "Clicked an element."

Two lines per screen, two lines per CTA — every other surface on the dashboard works the moment `Crossdeck.start(...)` succeeds. See the dedicated [Screen views](#screen-views) and [Tap labels](#tap-labels) sections for the pattern matrix (NavigationStack, NavigationLink, .sheet, .fullScreenCover, TabView; Button, Image with .onTapGesture, custom layouts).

### What you'll see on the dashboard

Once the bolt-ons are wired, the per-person journey on `/dashboard/people/ ` reads like a real human flow — a diary, not a vanity-metric row count. For a user moving through your app, the timeline will read something like:

```
08:32:51 AM   Opened the app
08:32:52 AM   Viewed "Library"             5s
08:32:57 AM   Tapped "Create Image"
08:32:57 AM   Viewed "Create Image"        32s
08:33:29 AM   Tapped "Generate"
08:33:29 AM   Viewed "Generated Image"     2m 14s
08:35:43 AM   Tapped "Subscribe"
08:35:44 AM   Viewed "Paywall"             8s
08:35:52 AM   Backgrounded the app         3m 8s
```

Each `Viewed` line carries a duration pill — that's time-on-screen, computed from the gap to the next event. The `Tapped` lines render the CTA label resolved from your `.crossdeckTap("Name")`. The `Opened the app` / `Backgrounded the app` prose is mobile-native (web stays on *"Arrived from X"* / *"Session ended"*) — driven by the event's platform property.

The **Pages tab** rolls these up: one row per screen name with views, distinct visitors, total events. Click any row to drill into a single screen's traffic with referrer host, country breakdown, and Top Actions (the `.crossdeckTap` labels grouped by frequency — your conversion signal for "Generate" vs "Subscribe" vs "Dismiss").

End-to-end latency from `cd.track(...)` to dashboard render is typically **3–8 seconds** (SDK batches at 20 events or 5s flush interval → projector resolves identity → Firestore write → dashboard onSnapshot listener fans the update out). Fast enough to feel real-time during a dogfood walkthrough; not synchronous.

## Initialise the SDK

Every Apple app constructs `Crossdeck` exactly once at process boot. The canonical place is the `App`'s `init()` (SwiftUI) or the `application(_:didFinishLaunchingWithOptions:)` (UIKit). The shipping pattern wraps `start` in `do`/`catch` and stores the client as `Optional` — a typo'd key should log + degrade telemetry, never crash the host app.

```
// SwiftUI — production-grade boot
@main
struct MyApp: App {
    let cd: Crossdeck?

    init() {
        cd = Self.startCrossdeck()
    }

    var body: some Scene {
        WindowGroup { ContentView().environment(\.crossdeck, cd) }
    }

    private static func startCrossdeck() -> Crossdeck? {
        // Build-configuration switch — Debug builds NEVER route into a
        // live dashboard, Release builds NEVER route into sandbox.
        #if DEBUG
        let publicKey = "cd_pub_test_…"
        let environment: CrossdeckEnvironment = .sandbox
        #else
        let publicKey = "cd_pub_live_…"
        let environment: CrossdeckEnvironment = .production
        #endif

        do {
            return try Crossdeck.start(options: CrossdeckOptions(
                appId: "app_ios_xxx",
                publicKey: publicKey,
                environment: environment
            ))
        } catch let error as CrossdeckError {
            // invalid_secret_key / env_mismatch / missing_app_id —
            // recoverable misconfig. Log + carry on without telemetry.
            assertionFailure("[Crossdeck] start failed (\(error.code)): \(error.message)")
            return nil
        } catch {
            assertionFailure("[Crossdeck] start failed: \(error)")
            return nil
        }
    }
}
```

Yes — Crossdeck is both the namespace and the instance type.

The factory call `let cd: Crossdeck = try Crossdeck.start(...)` looks duplicate-typed at first glance. It isn't. `Crossdeck` the type holds your client instance; `Crossdeck.start(...)` the static factory returns one. Mirrors Apple's own `URLSession` / `URLSession.shared` shape — single short symbol for both surfaces.

Read the injected client in any child view with `@Environment(\.crossdeck)`:

```
struct PaywallView: View {
    @Environment(\.crossdeck) var cd: Crossdeck?

    var body: some View {
        if cd?.isEntitled("pro") == true {
            ProContent()
        } else {
            Button("Upgrade") {
                cd?.track("upgrade_tapped")
            }
        }
    }
}
```

```
// UIKit — same pattern, AppDelegate edition
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    var cd: Crossdeck?

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        cd = do {
            try Crossdeck.start(options: CrossdeckOptions(
                appId: "app_ios_xxx",
                publicKey: publicKey, environment: environment
            ))
        } catch { return nil }()
        return true
    }
}
```

### How keys and environments fit together

The publishable-key prefix is authoritative. The `environment:` argument is a redundancy check, not the routing switch:

- `cd_pub_live_…` → routes to `.production`. `environment: .sandbox` with this prefix throws `env_mismatch`.

- `cd_pub_test_…` → routes to `.sandbox`. `environment: .production` with this prefix throws `env_mismatch`.

- `publicKey` not starting with `cd_pub_` → throws `invalid_secret_key` (never embed a `cd_sk_…` on the device).

- `appId` empty → throws `missing_app_id`.

Drive BOTH from your build configuration (xcconfig, `#if DEBUG`, environment-specific schemes) so a Debug build can never accidentally embed a live key and Release can never embed a test key. The mismatch check is the safety net, not the source of truth.

### Accessing the client outside SwiftUI

`@Environment(\.crossdeck)` only reaches Views. For services, view models, singletons, UIKit call sites, background workers, and Combine pipelines, use the `Crossdeck.current` static accessor — a process-singleton that returns the most-recently-started client. Thread-safe, Optional-typed so the call-site idiom is the same `cd?.` propagation used everywhere else. Shipped in v1.0.2.

```
// Anywhere in your codebase — services, view models, UIKit, Combine:
final class AuthService {
    static let shared = AuthService()

    func didSignIn(user: User) {
        Crossdeck.current?.identify(userId: user.id, email: user.email)
    }
}

// Read a paywall gate from a ViewModel. Expose the 3-state status, not a Bool —
// a Bool can't carry .resolving, which is exactly how a paying user flashes "free".
// See "Paid access — bank-grade gating" for the full rules (+ the StoreKit floor).
final class CheckoutViewModel: ObservableObject {
    var proStatus: EntitlementStatus? { Crossdeck.current?.entitlementStatus("pro") }
}

// Track an event from an AppDelegate method:
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) {
    Crossdeck.current?.track("push_received", properties: ["campaign": userInfo["campaign"] as? String ?? ""])
}
```

`Crossdeck.current` returns `nil` before `Crossdeck.start(...)` has succeeded in this process, or after the current client's `stop()` is called. The slot only clears when the stopped instance is the one currently advertised, so concurrent start+stop sequences on a second client never clobber the first.

Inside SwiftUI views, prefer `@Environment(\.crossdeck)` — it participates in SwiftUI's dependency tracking. The static accessor is for the 50% of the codebase that isn't a View.

**DI-container alternative.** If you use Swinject, Factory, or your own DI graph, register the instance once at boot and inject it through your container. Same pattern, different plumbing:

```
// In your App.init() right after startCrossdeck() resolves:
init() {
    let cd = Self.startCrossdeck()
    self.cd = cd
    Container.shared.register(Crossdeck?.self) { cd }
}
```

Keep **one** client per process; never call `Crossdeck.start` a second time. The validation that runs inside `start` is cheap, but the second instance would install a second pair of `UIApplication` lifecycle observers and the global error-capture singleton would route through the most-recent install — surprising side effects you don't want.

Publishable keys ( cd_pub_ ) are safe in the binary — secret keys ( cd_sk_ ) are not.

The publishable key (`cd_pub_live_…` / `cd_pub_test_…`) is the same shape Stripe uses for its client keys — safe to ship in your `.ipa`. Never embed a `cd_sk_…` secret key on the device; secret keys belong in your backend's secret store only.

### Lifecycle & idempotency

The `Crossdeck.start(options:)` call is synchronous. By the time it returns:

- `anonymousId` read or freshly generated and persisted to `UserDefaults`.

- Any buffered or pending events from the prior launch are rehydrated and a flush is scheduled.

- Super-properties + entitlement cache are loaded from disk.

- `UIApplication.willResignActive` / `didEnterBackground` observers are installed (iOS / iPadOS / Mac Catalyst).

- If `captureUncaughtExceptions: true`, the global `NSException` handler is installed and chained to any prior handler (Crashlytics, Sentry, etc. continue to receive the crash).

The SDK does not auto-initialise via a hidden background mechanism — you control the start moment. If you want deterministic teardown for tests, call `cd.stop()` to persist all in-memory state and reject subsequent `track` / `identify` calls with `CrossdeckError(code: "not_initialized")`.

## Identity & users

Three identity primitives — exactly the same vocabulary as the Web, Node, and React Native SDKs:

- **`anonymousId`** — the device handle, minted once on first launch, persisted in `UserDefaults`, never regenerated except by `reset()`. Mobile is the easy case: one stable ID per install, sent on *every* event, so one person is one identity — there is no per-origin re-mint like the web. Pre-identify analytics roll into the same person automatically.

- **`developerUserId`** — your auth provider's stable user ID (Firebase Auth's `uid`, Auth0's `sub`, Supabase's `id`, Sign In with Apple's `userIdentifier`, etc.). Passed in as the `userId:` argument to `identify(...)`; the alias call merges the `anonymousId` into this person.

- **`crossdeckCustomerId`** — Crossdeck's canonical `cdcust_…` record handle. The SDK learns it from the server's alias response, persists it for the life of the install, and stamps it on every subsequent event — so all of a person's activity (and their purchases) converge on one record. Returned to you directly by `identifyAndWait(...)`. NEVER confuse it with `developerUserId` — they live in different concept spaces; you always pass YOUR user ID to `identify(...)`, never a `cdcust_…`.

Purchases attach to the same identity — automatically.

Because the `anonymousId` is stable and rides with every event, a purchase the SDK forwards carries that same `anonymousId`, so the buyer's payment joins their existing record — one person, one customer, no orphan, no manual stitch (Swift 1.7.5+). This holds even when a third-party paywall (RevenueCat, etc.) owns the checkout — see [Apple in-app purchase: bank-grade attribution](#apple-attribution).

Pass your auth provider's user ID — never a cdcust_… .

The `userId:` argument to `identify(...)` is the same string you'd put on your backend's user-record primary key. `cdcust_…` handles are Crossdeck-side and server-issued — the SDK persists the one it's given and returns it via `identifyAndWait(...)`, but you never *pass* a `cdcust_…` into `identify(...)`.

### cd.identify(userId:email:traits:)

Links the device to a stable user identity. `email` is a first-class top-level option — the platform-wide universal anchor for identity-merge. Pass it whenever you have it; the backend uses it to coalesce the same person across devices and auth-provider migrations.

```
cd.identify(
    userId: "user_847",
    email: "wes@example.com",
    traits: [
        "plan": "pro",
        "signedUpAt": "2026-05-15",
    ]
)
```

Throws `CrossdeckError(code: "missing_user_id")` if `userId` is empty. Trait values are sanitised in-place (NaN / Infinity / functions / cyclic references replaced or dropped) with a debug warning — `identify` never throws on a bad trait.

Calling `identify(...)` sets `developerUserId` synchronously, clears the local entitlement cache, and fires `/identity/alias` in the background. The backend returns the canonical `crossdeckCustomerId` (`cdcust_…`) which the SDK persists for the lifetime of the install and stamps on every subsequent event.

Repeated identify is SAFE but not free — gate it.

Calling `identify(userId:)` twice with the same id is well-defined and won't break anything, but it isn't a no-op: each call unconditionally clears the entitlement cache (the bank-grade leak guard documented below) and re-fires `/identity/alias`. Most apps gate it with a `lastIdentifiedUserId` check so a screen that mounts ten times doesn't fire ten alias POSTs:

```
if let userId = AuthService.shared.currentUser?.id,
   userId != lastIdentifiedUserId {
    cd?.identify(userId: userId, email: AuthService.shared.currentUser?.email)
    lastIdentifiedUserId = userId
}
```

**Unconditional entitlement cache clear.** Every `identify(...)` call clears the entitlement cache — even a same-id re-identify. This is the defence that prevents a freshly-identified user from briefly seeing the prior user's entitlements through any sync read path. A tiny redundant cache rebuild is cheaper than a leaked entitlement across an account switch.

### cd.identifyAndWait(...) — when you need the cdcust_

The sync `identify` fires the alias POST in the background. `identifyAndWait` is the same call but awaits the result and returns the canonical `AliasResult` with the `crossdeckCustomerId`. Use it when the next step in your sign-in flow needs that handle:

- Cross-referencing the `cdcust_…` on your backend right after sign-in (e.g. attaching it to your user row).

- Showing the customer a Crossdeck-side support reference before they navigate away.

- Server-side analytics that join your data warehouse against the `cdcust_…` at sign-in time.

If you're just identifying the user for telemetry purposes, the sync `identify` is the right call — it sets the local identity instantly and your next `track` already carries the new `developerUserId`; the cdcust_ lands a few hundred ms later without anyone waiting.

```
let result = try await cd?.identifyAndWait(userId: user.id, email: user.email)
// result.crossdeckCustomerId is the cdcust_ — POST it to your backend now
if let cdcust = result?.crossdeckCustomerId {
    try await myBackend.attachCustomerHandle(userId: user.id, crossdeckCustomerId: cdcust)
}
```

### cd.reset() / cd.resetSync() — call on sign-out

**Critical for shared-device privacy.** Sign-out without `reset` leaves the prior user's `developerUserId`, entitlement cache, super-properties, and breadcrumbs on disk — the next sign-in would coalesce events from two different humans into the same Crossdeck record. `reset` wipes all five.

`reset()` is `async` because the SDK durably clears every per-user entitlement slot before it returns. Three safe call shapes — pick the one that fits your call site:

```
// 1. From a sync call site (button handler, AuthService method,
//    non-async closure) — the escape hatch. resetSync() flips the
//    entitlement tombstone immediately so isEntitled() returns false
//    across the wipe window, then drains the rest on a detached Task.
cd?.resetSync()

// 2. From a sync call site when you specifically want to wait for
//    the full drain — wrap in a Task.
Task { await cd?.reset() }

// 3. From an async context (an async func, .task modifier on a
//    SwiftUI View, an async closure) — call it directly.
await cd?.reset()
```

`reset` regenerates the `anonymousId` so the next anonymous session is fully unlinked from the prior identified user. It does *not* server-side erase the prior user's data — that's the separate `cd.forget()` async call (GDPR right-to-be-forgotten, POSTs `/identity/forget`). Both run safely without a current identity; `forget` calls `reset` internally on success or failure.

### iOS-specific auth provider examples

The shape is identical regardless of auth provider — pass your provider's stable user ID as `userId` and the email if you have it:

```
// Sign In with Apple
import AuthenticationServices

func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization auth: ASAuthorization) {
    guard let credential = auth.credential as? ASAuthorizationAppleIDCredential else { return }
    cd?.identify(
        userId: credential.user,
        email: credential.email,
        traits: [
            "name": [credential.fullName?.givenName, credential.fullName?.familyName].compactMap { $0 }.joined(separator: " ")
        ]
    )
}
```

```
// Firebase Auth iOS
import FirebaseAuth

Auth.auth().addStateDidChangeListener { _, user in
    guard let user else { cd?.resetSync(); return }
    cd?.identify(
        userId: user.uid,
        email: user.email,
        traits: ["displayName": user.displayName ?? ""]
    )
}
```

```
// Auth0 iOS
import Auth0

Auth0.authentication().userInfo(withAccessToken: token).start { result in
    if case .success(let profile) = result {
        cd?.identify(
            userId: profile.sub,
            email: profile.email,
            traits: ["name": profile.name ?? ""]
        )
    }
}
```

### Super properties — per-install enrichment

Register key/value pairs once; every subsequent `track()` auto-includes them on the event's `properties` bag. Persisted across launches in `UserDefaults`.

```
cd.registerSuperProperty("theme", "dark")
cd.registerSuperPropertyOnce("first_install_version", "2.3.1")
cd.unregisterSuperProperty("theme")
```

`registerSuperPropertyOnce` only writes if the key isn't already present — use it for values that should reflect first-install state and never get overwritten (e.g. install version, install date).

## Paid access — bank-grade gating

**Access is owned by the device's Apple-signed StoreKit receipt — instantly, offline, permanently.** A returning subscriber is entitled the moment they open your app, before any backend round-trip and before Crossdeck has attributed the purchase to a person. Crossdeck's job is *attribution* (which customer owns the purchase); that may lag and **must never gate access**. Three rules make your paywall bank-grade — follow all three.

**🛑 A payer must never see a flash of “not Pro.”** Not during launch, not offline, not if the SDK fails to start. The three rules below each close one way that can happen. Skipping any one of them ships a paywall that occasionally charges a paying user again or hides what they already bought.

### 1. Gate on entitlementStatus , never the isEntitled Bool

The resolver is **three-state**. `isEntitled("pro")` is a convenience that collapses the middle state to `false` — which briefly reads a *paying* user as free while the receipt is still resolving (a cold-launch read in flight, or a fresh install that is offline and hasn't synced the product→entitlement map yet, but *does* hold a valid device receipt). For any gate a user can see, read `entitlementStatus` and give `.resolving` a neutral “checking…” treatment — **never the free tier and never the paywall**:

```
switch Crossdeck.current?.entitlementStatus("pro") {
case .entitled:
    ProContent()                 // full access
case .resolving, nil:
    ProContentOrSpinner()        // checking — retain last-known-entitled; NEVER show the paywall here
case .notEntitled:
    Paywall()                    // the ONLY state that shows the paywall
}
```

Apply this everywhere a paying user could be shown less than they paid for: the paywall, every feature/limit gate, the “Active / Free” label, stats. `isEntitled` is fine only for non-user-facing logic where a `.resolving` flash has no cost.

### 2. The floor — never ?? false ; fall back to the device receipt

A payment gate may **never default-deny.** If `Crossdeck.start(...)` throws, `Crossdeck.current` is `nil`, and `Crossdeck.current?.isEntitled("pro") ?? false` hard-denies *every* user — payers included. Never write that. When the SDK is unavailable, read access **directly from StoreKit** — the device receipt is the floor, and it is authoritative:

```
// The receipt owns access. This works with the SDK absent, offline, at first frame.
func hasProAccess() async -> Bool {
    if Crossdeck.current?.entitlementStatus("pro") == .entitled { return true }
    // SDK nil or still resolving → ask StoreKit itself. Never `?? false`.
    for await result in Transaction.currentEntitlements {
        if case .verified(let t) = result, t.productType == .autoRenewable, t.revocationDate == nil {
            return true
        }
    }
    return false
}
```

**Don't retry `start()` — make its failure loud instead.** `start()` throws on exactly three *permanent* config errors (a key that isn't `cd_pub_…`, an environment that disagrees with the key prefix, or an empty `appId`), all validated at the top; there is no transient or network failure mode. Retrying re-runs the same validation against the same hardcoded values and fails identically — dead code. The real risk is that a bad key ships to **production**, where `assertionFailure` is a no-op, so the failure is *silent* — you notice days later via missing dashboard data. Make it observable **out-of-band** (you cannot report a Crossdeck start-failure through Crossdeck — the reporter is the thing that failed):

```
do {
    cd = try Crossdeck.start(options: .init(publicKey: key, appId: appId, environment: env))
} catch {
    cd = nil
    // os.Logger → Apple's unified log (Console.app, sysdiagnose, MetricKit) —
    // NOT the Crossdeck dashboard. Pair it with an INDEPENDENT signal (your
    // crash reporter / analytics — Crashlytics, Sentry, TelemetryDeck, or a
    // direct backend beacon) so a misconfigured production build pages you now.
    Logger(subsystem: "com.yourapp", category: "crossdeck")
        .critical("Crossdeck.start failed: \(error.localizedDescription, privacy: .public)")
    assertionFailure("Crossdeck.start failed — fix the key/env/appId before shipping")
}
```

**Why not the Crossdeck dashboard?** Crossdeck surfaces errors you send via `captureError(...)` / uncaught `NSException` — both need a *running* SDK. A `start()` failure means there is no client to report through, so the signal has to ride a channel that doesn't depend on Crossdeck. The StoreKit floor above is what keeps a *payer* unaffected meanwhile; this makes sure *you* find out.

### 3. Bind every purchase to the stable device identity

So Crossdeck can attribute a purchase to the right person (and never mint a duplicate), pass the device's `appAccountToken` on every StoreKit purchase. `appAccountTokenForCurrentIdentity()` returns a **non-optional `UUID`** — it is never nil, minted once and Keychain-anchored so it is stable for the life of the install, across restarts *and* reinstalls. Call it directly — do not guard it with `if let`, and do not let a purchase proceed unbound:

```
let token = Crossdeck.current?.appAccountTokenForCurrentIdentity()
             ?? UUID()   // only if the SDK is absent; with it present the token is always the stable UID
let result = try await product.purchase(options: [.appAccountToken(token)])
```

Ensure the SDK is started before any purchasable UI is interactive, so the token is always the stable device UID — a purchase that fires without it lands unattributed (a “needs review” that can’t be matched to a person). With the token always set, attribution is exact and every purchase binds to one stable identity.

**Reject list** — treat any of these as a bug: gating a paywall/feature on the Bool `isEntitled` (use `entitlementStatus`) · any `?? false` that can hard-deny a payer on infra failure · a purchase that fires without `appAccountToken` · treating the `.resolving` flash as “cosmetic, ship it” · access that waits on the network or backend attribution.

## Events & analytics

### cd.track(_:properties:)

Synchronous from the caller's perspective — the event is validated, scrubbed, enriched, and enqueued on a background actor; the network round-trip happens in batches. Throws `CrossdeckError` for malformed input (missing event name, invalid property shape) before the event ever touches the queue.

```
cd.track("checkout_completed", properties: [
    "amount_usd": 29,
    "plan": "pro",
])
```

Properties are validated at the SDK boundary before the event enters the queue. One bad property can never crash the serialiser at flush time:

- `Double.nan` and `Double.infinity` are rejected (no JSON representation).

- Reference-type cycles (an `NSMutableDictionary` referencing itself) are rejected with `event_properties_cyclic`.

- Nesting deeper than 32 levels is rejected with `event_properties_too_deep`.

- Non-encodable instances (a custom class with no Codable conformance) are rejected with `event_property_not_encodable`.

- Empty event names are rejected with `missing_event_name`.

Enrichment order: super-properties → device info (`platform`, `os_version`, `locale`, `timezone`, `app_bundle_id`, `app_version`, `sdk_name`, `sdk_version`) → caller-supplied properties. The caller's bag wins on key collision.

### The durable queue

The event queue is the analytics workhorse — used by `track()` and by every error report:

- **Buffer cap**: 1000 events. Above that, the OLDEST events are evicted (the newest signal is most likely the most diagnostically useful) and an overflow signal is emitted to the debug logger.

- **Batch size**: default 20 events per HTTP POST. Configurable via `queueConfig.batchSize`.

- **Flush interval**: default 5 s between time-triggered flushes. Configurable via `queueConfig.flushIntervalMs`.

- **Per-batch `Idempotency-Key`**: minted once on first send, reused across every retry of the same batch. The server short-circuits duplicates by key.

- **Pending-batch slot**: when a flush starts, the head-of-queue is MOVED into a dedicated `pendingBatch` slot. New `enqueue` calls go into the fresh buffer behind it. The pending slot is only cleared when the server confirms success — a crash mid-HTTP-request leaves the pending batch persisted to `UserDefaults` for the next launch to re-send with the same idempotency key.

- **4xx hard stop**: permanent client errors (`invalid_request_error`, `authentication_error`, `permission_error`) drain through the `onPermanentFailure` callback and are NOT retried. Retrying a payload the server has definitively rejected wastes battery and (worse) blocks newer events behind a dead batch.

- **Retry policy**: exponential backoff with full jitter on `408` / `429` / `5xx`. Honours `Retry-After` headers even above the local `maxMs` cap (server is authoritative on its rate budget), clamped at 24 h as a sanity ceiling.

### cd.flush() — async drain

Returns when the in-flight batch resolves — success or failure. On failure, events stay queued for the next retry; the awaited call does not throw. Use before app-shutdown to drain explicitly.

```
await cd.flush()
```

### Permanent failure observability — onPermanentFailure

Closure invoked when a batch fails permanently (4xx that won't change on retry, or retry budget exhausted). Your last chance to observe events that will never deliver — typical use is logging them to your own observability stack and surfacing a diagnostic banner for `invalid_request_error` cases.

```
let options = CrossdeckOptions(
    appId: "app_ios_xxx",
    publicKey: "cd_pub_live_…",
    environment: .production,
    onPermanentFailure: { events, error in
        Logger().error("Crossdeck batch dropped: \(error.code) — \(events.count) events")
    }
)
```

## Screen views

Screen views are how the dashboard's Pages tab tells you which screens your users actually use — the iOS equivalent of "/dashboard · 63 views" on a web app's Pages tab. The SDK fires `page.viewed` on every screen-enter; the dashboard groups by the screen name so you can see in one glance which features get traffic and which sit unused.

### UIKit auto-tracks. SwiftUI needs one line per screen.

**UIKit hosts** — anywhere your app's screens are `UIViewController` subclasses — fire `page.viewed` automatically via a `viewDidAppear` swizzle. Class name becomes the `screen` property; a non-empty `title` on the controller becomes the title. Nothing to wire.

**Pure-SwiftUI hosts** are the tricky case. SwiftUI deliberately hides destination names from the runtime — every screen is backed by a generic `UIHostingController `, which is on the swizzle's denylist (otherwise every push/pop fires the same useless host name). The result: a pure-SwiftUI app emits zero `page.viewed` events without help.

Industry-standard fix — and the same shape Mixpanel, Amplitude, and PostHog ship: **one `.crossdeckScreen("Name")` modifier per user-visible screen**. It fires `page.viewed` with `{ screen, title }` on `.onAppear`; the Pages backend groups on `screen` when no `url`/`path` is present, so your iOS app populates Pages identically to a web app.

### Where to tag — five patterns cover every SwiftUI app

Tag the **destination view**, not the container that navigates to it. Tagging a container fires on every redraw or push-pop.

**1. `NavigationStack` with typed destinations:**

```
struct ContentView: View {
    var body: some View {
        NavigationStack {
            HomeView().crossdeckScreen("Home")
                .navigationDestination(for: ImagePrompt.self) { prompt in
                    CreateImageView(prompt: prompt)
                        .crossdeckScreen("Create Image")
                }
        }
    }
}
```

**2. `NavigationLink(destination:)`:**

```
NavigationLink(destination: SettingsView().crossdeckScreen("Settings")) {
    Label("Settings", systemImage: "gear")
}
```

**3. `.sheet` and `.fullScreenCover` content:**

```
.sheet(isPresented: $showPaywall) {
    PaywallView().crossdeckScreen("Paywall")
}

.fullScreenCover(item: $editingAsset) { asset in
    AssetEditor(asset: asset).crossdeckScreen("Asset Editor")
}
```

**4. `TabView` — tag each tab's root view, not the `TabView` itself:**

```
TabView {
    LibraryView().crossdeckScreen("Library")
        .tabItem { Label("Library", systemImage: "photo.stack") }
    CreateView().crossdeckScreen("Create")
        .tabItem { Label("Create", systemImage: "sparkles") }
    AccountView().crossdeckScreen("Account")
        .tabItem { Label("Account", systemImage: "person") }
}
```

**5. With extra properties** — when you want the screen view to carry context the dashboard can break down by (e.g. plan tier, A/B variant). Properties flow through the same `cd.track` validation as any other event:

```
PaywallView()
    .crossdeckScreen("Paywall", properties: ["variant": experiment])
```

### Don't tag these

Tagging the wrong layer either double-fires (visible to you as inflated views on Pages) or fires the wrong name. The four common mistakes:

- `NavigationStack { … }.crossdeckScreen("…")` — only a problem once the stack actually pushes. A single-screen "chrome" NavigationStack with no `.navigationDestination(for:)` modifiers and no `NavigationLink`s fires `onAppear` exactly once and is fine to tag. The risk is silent regression: adding a destination later quietly introduces double-fires because every push/pop replays `onAppear` on the stack root. **Future-proof rule:** if there's any chance the stack will gain destinations, tag the destination View instead. If you accept the risk for a navigation-less chrome stack, leave it tagged.

- `TabView { … }.crossdeckScreen("…")` — same problem; fires on every tab switch with the same name.

- `List { ForEach(items) { … .crossdeckScreen(…) } }` — fires per-row; turns one screen view into N.

- Layout wrappers (`VStack`, `HStack`, `ZStack`, `Group`) — they're not the screen. Tag the screen-level View that's *inside* the stack.

### Naming conventions

The screen name becomes the grouping key on the dashboard. Two rules:

- **Human-readable and stable.** "Create Image" (good); "CreateImageViewV3" (leaks SwiftUI type names; breaks the moment you rename); "create_image_screen" (works but reads worse on the dashboard).

- **No high-cardinality data, no PII.** "Create – \(promptId)" generates a new Pages row per prompt and aggregates to nothing useful; `"Profile – \(user.email)"` publishes a user's email to the grouping axis. If you want per-prompt or per-user breakdowns, pass them as `properties:` — they ride alongside the screen name without polluting the grouping key.

### Opt-out

The modifier is a thin wrapper around `cd.track("page.viewed", …)` — when `autoTrack: .off` is set in `CrossdeckOptions`, the underlying call still fires (your code asked for it explicitly; the SDK respects the contract). For strict-consent flows that want zero `page.viewed` events, remove the modifier from the view tree or guard it on a consent flag your app already owns.

## Tap labels

Tap labels are how the dashboard's per-person journey and Pages → Top Actions tell you *which CTA* the user pressed — "Clicked 'Create Image'" instead of the useless "Clicked an element."

### UIKit auto-tracks. SwiftUI buttons on iOS 16+ need one line per CTA.

**UIKit controls** (UIButton, UIControl subclasses) fire `element.clicked` automatically with the button's title / accessibility label resolved via the `UIControl.sendAction` swizzle. Nothing to wire.

**SwiftUI buttons on iOS 13–15** are also caught — the SDK's `UIWindow.sendEvent` hit-test walks up 16 ancestors and down 6 descendants looking for an `accessibilityLabel` or `UILabel.text`. Most older-pipeline SwiftUI buttons render through `UILabel` primitives the descendant search finds.

**SwiftUI buttons on iOS 16+ genuinely don't expose the label.** Apple changed the SwiftUI rendering pipeline — `Button("Create Image")`'s text is drawn straight to a `CALayer` via Metal, and never lives on any `UILabel.text` or `UIView.accessibilityLabel` the runtime can read. The accessibility merge happens at the SwiftUI virtual-view layer above UIKit; `UIView.accessibilityLabel` stays nil on the rendered hierarchy. This is an Apple-imposed limit, not a CrossDeck regression — every analytics SDK hits the same wall.

Industry-standard fix — what Mixpanel / Amplitude / PostHog ship: **one `.crossdeckTap("Name")` modifier per important CTA**. Same shape as `.crossdeckScreen`. The wrapped view's own action still runs (`.simultaneousGesture` attaches the tap-capture gesture alongside the button's own).

### Where to use it

```
// Button — wrapped action still fires.
Button { generateImage() } label: { Text("Create Image") }
    .crossdeckTap("Create Image")

// Image / Text / custom card — anywhere a tappable surface lives.
Image(systemName: "sparkles")
    .onTapGesture { generateImage() }
    .crossdeckTap("Generate sparkle")

// With extra properties — A/B variant, plan tier, prompt class.
Button { showPaywall() } label: { Text("Subscribe") }
    .crossdeckTap("Subscribe", properties: ["plan": "pro_monthly"])
```

### What to tag, what to skip

- **Tag the funnel CTAs** — Generate, Subscribe, Continue, Sign Out, Save, Submit. The buttons whose press count answers a product question.

- **Skip incidental interactions** — sheet dismissals, back navigation, scroll gestures, decorative tap targets. Untagged taps still flow through auto-track and show up as "tapped something" — useful as a denominator, not as a CTA.

- **One name per CTA, stable across releases** — "Create Image" (good); "create_image_btn_v3" (leaks internals; breaks on rename); "Subscribe – `$plan`" (high-cardinality, breaks Top Actions aggregation — use the `properties` arg for the plan instead).

### Opt-out

The modifier is a thin wrapper around `cd.track("element.clicked", …)` — autoTrack settings don't gate it (your code asked for it explicitly). For strict-consent flows that want zero `element.clicked` events, remove the modifier from the view tree or guard it on a consent flag your app already owns.

## Entitlements

Entitlements are how Crossdeck answers "does this customer get this feature right now?" — without exposing your subscription rail's internal product IDs to the client. The Swift SDK gives you two ways to consult the cache:

### cd.isEntitled(_:)

Synchronous, cache-only. Returns `true` if the cached set for the currently-identified `developerUserId` contains the entitlement key; `false` otherwise. Safe to call from a SwiftUI view body or a UIKit tap handler — it never blocks on network.

```
if cd.isEntitled("pro") {
    showProFeature()
} else {
    showUpgradeSheet()
}
```

**Customer-scoped reads.** The cache is keyed on `(developerUserId, entitlements)`. If the cached snapshot was for a different customer (e.g. after a sign-out / sign-in flow that the entitlement fetch hasn't yet refreshed), `isEntitled(...)` returns `false` — never silently leaks the prior user's entitlements.

### cd.entitlementsForCurrentCustomer()

Returns the full `[PublicEntitlement]` array for the currently-identified customer, or `nil` if no customer is identified or the cache has nothing for them yet. Expired entitlements (a `validUntil` in the past) are filtered out. Use when you need to render an entitlement list (e.g. a settings screen showing the user's plan features). For a paywall that only needs the active keys, the convenience `cd.activeEntitlementKeys() -> [String]?` maps the same set down to just the key strings.

```
guard let ents = cd.entitlementsForCurrentCustomer() else {
    // Not identified, or cache cold
    return
}
List(ents, id: \.key) { Text($0.key) }

// Or just the active keys:
let keys = cd.activeEntitlementKeys() ?? []
```

Cache freshness model.

v1.0.1 ships the full server-side surface: `getEntitlements()` async / GET `/entitlements` hydrates the cache; `syncPurchases(rail: .apple, signedTransactionInfo:...)` async forwards StoreKit 2 JWS evidence to `/purchases/sync` for verification + entitlement projection; `onEntitlementsChange(...)` subscribes to cache mutations. On 5xx / network failure the cache stays authoritative (`markRefreshFailed` records the failure timestamp without invalidating the last-known-good state — a paying customer never falls back to free during a Crossdeck outage). Google Play / cross-rail sync ships in v1.1 alongside the React Native + Android Play Billing surface; v1.0.1 throws `rail_not_supported` for any non-Apple rail.

### Verify your install

The contract: after `try Crossdeck.start(options: ...)` returns, the dashboard's **iOS / macOS · Swift SDK** row flips **LIVE** within ~30 seconds. The signal is the boot heartbeat — a GET on `/sdk/heartbeat` that fires automatically from the synchronous start path. The backend reads `appId` + `environment` from the API key, plus the `Crossdeck-Sdk-Version` header the SDK ships on every request, and writes a record to `projects/{p}.sdkHeartbeats.swift.{env}` that the dashboard polls every few seconds.

What you should see, in order:

- **Within 200ms of start** — the heartbeat request lands. `projects/{p}.appHeartbeats.{appId}` + `projects/{p}.sdkHeartbeats.swift.{env}` both write.

- **Within ~5s of dashboard refresh** — the "Swift SDK" row on Dashboard → SDKs flips green.

- **Within 5s of your first `cd.track(...)` call** — the event appears in Dashboard → Activity → Realtime.

If the row stays dark for more than 60 seconds, check the troubleshooting section below for `publicKey` + `environment` alignment first — a misconfigured prefix is the most common cause.

## Error capture

Two roads into the SDK's error pipeline:

- **Uncaught `NSException` capture** (opt-in via `captureUncaughtExceptions: true`). Installs a global handler that snapshots the exception, attaches breadcrumbs, runs your `beforeSendError` hook, and ships the event before the process dies.

- **Manual `captureError(_:handled:)`** — call from a `do/catch` block to ship a handled error. Same enrichment, same pipeline, same idempotency.

### Uncaught exception capture

```
let cd = try! Crossdeck.start(options: CrossdeckOptions(
    appId: "app_ios_xxx",
    publicKey: "cd_pub_live_…",
    environment: .production,
    captureUncaughtExceptions: true
))
```

`NSSetUncaughtExceptionHandler` is a process-wide C hook. Crashlytics, Sentry, Bugsnag, and Firebase Crashlytics all want it. The Swift SDK chains: on install we capture the prior handler (whoever registered last) and invoke them after our own snapshot. Crash reporters that came before Crossdeck still receive the crash; Crashlytics dashboards stay populated. Apple does not provide a clean uninstall API for `NSSetUncaughtExceptionHandler` — if you stop capture via `cd.stop()`, our chain wrapper stays in place but the per-Crossdeck routing is removed.

### Manual capture

```
do {
    try performRiskyOperation()
} catch {
    cd.captureError(error, handled: true)
}
```

Handled errors flow through the same path as uncaught ones — same fingerprinting (top-5-frame hash), same breadcrumb attachment, same `beforeSend` hook. The `handled` flag is preserved on the wire so the dashboard distinguishes them.

### Breadcrumbs — context before the crash

50-entry ring buffer of timestamped events leading up to an error. Capacity configurable via `breadcrumbCapacity`. Every `track()` call adds a breadcrumb automatically; you can add custom ones from your UI handlers:

```
cd.addBreadcrumb(Breadcrumb(
    category: .ui,
    level: .info,
    message: "tapped subscribe button",
    data: ["plan": "annual"]
))
```

When an error fires, the current breadcrumb buffer is attached as `error.breadcrumbs` on the resulting event. The buffer is process-scoped — survives across screens, dropped on `reset()`.

### Fingerprinting — group identical errors

Errors are auto-fingerprinted from the top 5 stack frames (`module:symbol` joined). Two crashes from the same code path collapse to one issue in the dashboard. Stack symbols are parsed from `Thread.callStackSymbols` — addresses (ASLR-randomised per launch) are stripped so the fingerprint is stable across runs.

The SDK does NOT call `swift_demangle` at capture time (it's not on the public platform surface and pulling it via `dlsym` is brittle). Mangled symbols round-trip fine; the dashboard demangles server-side where the Swift toolchain is available.

### beforeSendError — the redaction last line

Final filter before an error event leaves the device. Return `nil` to drop the error entirely (e.g. a known-noisy framework exception your team has decided to ignore). Return a mutated `CapturedError` to scrub PII the auto-scrubber missed (rare — the recursive PII scrubber catches emails and cards in every field by default).

```
let options = CrossdeckOptions(
    appId: "app_ios_xxx",
    publicKey: "cd_pub_live_…",
    environment: .production,
    captureUncaughtExceptions: true,
    beforeSendError: { event in
        // Drop a known-noisy framework exception
        if event.message.contains("NSNotificationCenter is deprecated") {
            return nil
        }
        return event
    }
)
```

### Self-request skip — no feedback loops

The error-capture pipeline does NOT report HTTP failures against the Crossdeck ingest endpoint itself. Without this guard, a transient ingest failure would generate an `$error` event, which would itself fail to send, which would generate another error, ad infinitum. The check is a case-insensitive hostname compare against the configured `endpoint`'s host.

## App lifecycle

On iOS / iPadOS / Mac Catalyst, the SDK observes `UIApplication` notifications automatically. You do not need to wire anything yourself.

Notification SDK behaviour

willResignActive Persist entire buffer + pending batch to UserDefaults immediately. Cheap; runs on the main thread but only writes <50 KB typical.
didEnterBackground Best-effort flush() using iOS's background-execution budget (typically a few seconds). Whatever fits ships; the rest survives in the durable buffer for next launch.
Force-quit while suspended Nothing happens at runtime (the app is gone) — but the durable buffer persisted at willResignActive rehydrates on next launch and re-sends with the original idempotency keys.

On macOS / tvOS / watchOS the equivalent observers are not installed (no `UIApplication`). The durable buffer still persists on every `enqueue`, and you can call `cd.flush()` from any `NSApplicationWillTerminate` observer if you want explicit drain.

## Privacy & consent

Two channels of consent, tracked independently:

- **Analytics consent** — gates `track()`. With analytics off, the SDK silently drops the event after the consent check (the debug logger fires `consent.denied` so you can verify the gate is wired).

- **Errors consent** — separately gated from analytics. With `consent.errors` off the error pipeline drops `$error` events silently (debug logger fires `sdk.consent_denied` with `channel: errors` so you can verify the gate is wired). Both channels default GRANT.

Both channels default GRANT — no explicit setup required. Wire `setConsent(...)` for an opt-out flow (EU cookie banner, COPPA age gate):

```
// User declines in your consent UI:
cd.setConsent(ConsentState(analytics: false, errors: false))

// User accepts — restore full access:
cd.setConsent(ConsentState(analytics: true, errors: true))
```

### PII scrubber

On by default. Two patterns are scrubbed before any event leaves the device, recursively across nested dictionaries and arrays:

- **Email addresses** → ` `

- **Payment card numbers** (13–19 digits, optional space/hyphen separators) → ` `

The tokens (` ` / ` `) are the platform-wide vocabulary — identical to the Web, Node, React Native, and backend allow-list. The scrubber walks reference-type containers (`NSDictionary` / `NSArray`) FIRST so object-identity-based cycle detection catches loops before falling back to depth-cap.

The scrub runs BEFORE the event is enqueued, so the persisted queue never holds raw PII. A crash dump of disk state is safe to share with support.

```
cd.setScrubPII(false) // disables — use only when consumer has explicit consent to ship raw values
```

### App Store privacy manifest ( PrivacyInfo.xcprivacy )

iOS 17+ App Store review rejects any binary that calls a required-reason API without a privacy manifest. The Swift SDK uses `UserDefaults` for queue persistence and `anonymousId` storage — a required-reason API since Xcode 15. `@cross-deck/swift` does not yet bundle its own `PrivacyInfo.xcprivacy`. Until it does, add the following declaration to your **app target's** `PrivacyInfo.xcprivacy` (create one via Xcode → File → New → App Privacy if your project doesn't have one):

```
<key>NSPrivacyAccessedAPITypes</key>
<array>
    <dict>
        <key>NSPrivacyAccessedAPIType</key>
        <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
        <key>NSPrivacyAccessedAPITypeReasons</key>
        <array>
            <string>CA92.1</string>
        </array>
    </dict>
</array>
```

Reason `CA92.1` covers same-app `UserDefaults` access. If your target already declares `NSPrivacyAccessedAPICategoryUserDefaults`, add `CA92.1` to the existing reasons array — one entry per category, multiple reasons. Crossdeck will ship a bundled manifest in a future minor release; once it does, this manual declaration is only needed for your own `UserDefaults` use. Full reason-code catalogue: [developer.apple.com → Describing use of required reason API](https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api).

## Configuration reference

Every field on `CrossdeckOptions`:

Option Type Default Notes

appId String — Required. Crossdeck App ID from the dashboard (e.g. app_ios_xxx ).
publicKey String — Required. Publishable key starting with cd_pub_ ( cd_pub_live_… or cd_pub_test_… ).
environment CrossdeckEnvironment — Required. .production or .sandbox . Must match the publicKey prefix; mismatch throws env_mismatch at start.
baseUrl URL? https://api.cross-deck.com/v1 Override for self-hosted setups or the local emulator.
urlSession URLSession? nil (SDK builds one) Pass a custom session for proxy / App Group transport / test mocking.
storage Storage? UserDefaultsStorage() Pass MemoryStorage() for ephemeral or test use.
initialConsent ConsentState (analytics: true, errors: true) Default-GRANT both channels (matches Web/Node/RN). Wire setConsent(...) for an opt-out flow (cookie banner / EU age gate).
scrubPII Bool true Recursive PII scrub on nested properties.
queueConfig.batchSize Int 20 Events per HTTP POST.
queueConfig.flushIntervalMs Int 5000 Time-triggered flush cadence.
queueConfig.maxBufferSize Int 1000 Hard cap. Overflow drops OLDEST.
queueConfig.retry.baseMs Int 1000 Initial backoff for retryable failures.
queueConfig.retry.maxMs Int 30000 Backoff cap (overridden by Retry-After if larger).
queueConfig.retry.maxAttempts Int 5 After exhaustion, batch routes to onPermanentFailure .
breadcrumbCapacity Int 50 Ring-buffer depth.
captureUncaughtExceptions Bool false Opt in to chain into NSSetUncaughtExceptionHandler .
beforeSendError (CapturedError) -> CapturedError? nil Filter / mutate / drop before ship.
onPermanentFailure (events, error) -> Void nil Observe never-delivered batches.
debugLogger DebugLogger noopDebugLogger Pass defaultDebugLogger() for os.Logger routing during development.

## API reference

### Lifecycle

- `Crossdeck.start(options:) throws -> Crossdeck` — designated start path. Throws on invalid_secret_key, env_mismatch, or missing_app_id.

- `Crossdeck.current -> Crossdeck?` — **static** process-singleton accessor (v1.0.2+). Returns the most-recently-started client, or `nil` before `start` has succeeded / after the current client's `stop` is called. Thread-safe. Use from services, view models, AppDelegate methods, background workers — anywhere outside a SwiftUI view body where `@Environment(\.crossdeck)` isn't an option.

- `cd.stop()` — persist + reject subsequent calls. Clears the `Crossdeck.current` slot iff this instance is the one advertised. Idempotent.

- `cd.flush() async` — drain. Returns when the in-flight batch resolves.

- `cd.heartbeat() async -> HeartbeatResponse?` — GET `/sdk/heartbeat`. Auto-fires once from `start(...)`. Best-effort; returns `nil` on network failure.

### Identity

- `cd.identify(userId:email:traits:)` — set user id + optional traits. Non-throwing (v1.1.0+); empty userId is dropped with a debug log.

- `cd.identifyAndWait(userId:email:traits:) async throws -> AliasResult` — identify and await the canonical `crossdeckCustomerId`.

- `cd.forget() async throws` — GDPR right-to-be-forgotten. POSTs `/identity/forget`; local state (identity + entitlements + super-properties + breadcrumbs) is always wiped, even if the server call fails.

- `cd.reset()` — sign-out path; regenerate anonymousId + wipe identity, entitlement cache, super-properties, breadcrumbs. `async`, non-throwing (v1.1.0+).

- `cd.resetSync()` — sync escape hatch with identical wipe semantics. Flips the entitlement tombstone immediately so `isEntitled()` returns false across the wipe window, then drains the rest on a detached Task. Use from sync call sites (button handlers, AuthService methods, non-async closures) where `await` isn't an option.

### Events

- `cd.track(_:properties:)` — enqueue an event. Non-throwing (v1.1.0+).

- `cd.registerSuperProperty(_:_:)` — set persistent enrichment key.

- `cd.registerSuperPropertyOnce(_:_:)` — set if absent.

- `cd.unregisterSuperProperty(_:)` — remove key.

- `cd.stats() async -> QueueStats` — buffered / pending / next-retry-at snapshot.

### Entitlements

- `cd.isEntitled(_:) -> Bool` — sync cache check.

- `cd.entitlementsForCurrentCustomer() -> [PublicEntitlement]?` — sync full set (expired entitlements filtered out).

- `cd.activeEntitlementKeys() -> [String]?` — convenience: just the active entitlement keys.

- `cd.getEntitlements() async throws -> [PublicEntitlement]` — GET `/entitlements`; hydrates the cache. On 5xx/network failure, preserves the existing cache (`markRefreshFailed`) so a Crossdeck outage never downgrades a paying customer to free.

- `cd.syncPurchases(rail:signedTransactionInfo:signedRenewalInfo:) async throws -> PurchaseResult` — POST `/purchases/sync` with StoreKit 2 JWS evidence; hydrates the cache. Apple rail only in v1.0.1; non-Apple rails throw `rail_not_supported`.

- `Crossdeck.appAccountTokenForCurrentIdentity() -> UUID` — Apple rail attribution token (v1.5.0+). Pass to `Product.PurchaseOption.appAccountToken(_:)` at every StoreKit purchase site so the chain stays bound to the correct app user across `identify()`, merges, and SSO upgrades. Lazy-mints on first call, persists, wipes on `reset()`. See [Apple in-app purchase: bank-grade attribution](#apple-attribution) for the full contract.

- `cd.onEntitlementsChange(_:) async -> UUID` — subscribe to cache mutations; returns a subscription token. Detach with `cd.unsubscribeFromEntitlements(_ token: UUID) async`.

## Apple in-app purchase: bank-grade attribution

If your iOS / macOS / tvOS app makes StoreKit purchases, every purchase site needs **one extra line** on the purchase options:

```
let token: UUID = Crossdeck.appAccountTokenForCurrentIdentity()
let result = try await product.purchase(options: [
    .appAccountToken(token)
])
```

That line closes **Shape 2 (identity-key mismatch)** on the Apple rail — the silent-subscription-orphan bug that grows linearly with auto-tracked purchases when the developer's `identify()` value changes mid-stream (anonymous → logged in, account merge, SSO upgrade). Apple's transaction records are permanent, so a token that goes stale never recovers. The helper guarantees the token is one stable random UUID per install, decoupled from every other identifier in your app.

### Don&rsquo;t own the purchase() call? Works alongside RevenueCat (or any paywall)

**You don&rsquo;t have to rip out your paywall to get Crossdeck&rsquo;s attribution.** Crossdeck doesn&rsquo;t need to own your checkout — it observes the device&rsquo;s own StoreKit stream (`Transaction.updates` for new purchases, `Transaction.currentEntitlements` for existing ones) *independent of whoever triggered `purchase()`*, and stamps its own identity (the install-stable `anonymousId` + `appAccountToken`) onto the signed transaction it forwards. So a purchase made through **RevenueCat, Superwall, Adapty, Glassfy, or hand-rolled StoreKit** is still observed, attributed, and stitched to the right buyer. Drop Crossdeck in alongside whatever you already use and it sees the truth.

One requirement, and it&rsquo;s a one-liner — turn the StoreKit observation on at start:

```
let cd = try Crossdeck.start(options: CrossdeckOptions(
    appId: "app_ios_xxx",
    publicKey: "cd_pub_live_…",
    environment: .production,
    automaticPurchaseTracking: true   // observe purchases from any paywall
))
```

- **`automaticPurchaseTracking: true`** (off by default) — the `Transaction.updates` listener picks up every new purchase / renewal / refund the moment StoreKit signs it, no matter who initiated it.

- **`automaticAppleEntitlementSync`** (on by default) — sweeps `Transaction.currentEntitlements` on launch so your *existing* subscribers self-heal the first time they open the Crossdeck-enabled build.

- **Prefer explicit?** Call `cd.syncPurchases(...)` from your paywall&rsquo;s purchase-completion callback — deterministic, the instant the third-party purchase succeeds.

Each forwarded purchase carries the device&rsquo;s `anonymousId`, so the backend joins it straight to that buyer&rsquo;s existing record — one person, one customer, no orphan, no manual stitch (Swift SDK 1.7.5+). Enable none of these and a third-party checkout reaches Crossdeck only through Apple&rsquo;s tokenless server-to-server webhook, which carries no identity — so it lands as an unlinked customer needing a manual stitch. That&rsquo;s the one failure mode, and the one-liner removes it.

### What the helper does

- **First call lazy-mints a fresh `UUID()`**, persists it under the storage key `crossdeck.apple_app_account_token`, and returns it. Returns a non-optional `UUID` — the exact type `Product.PurchaseOption.appAccountToken(_:)` wants.

- **Every subsequent call returns the same value forever**, within the install/sign-in session. Identity mutations (anonymous → identified, traits updated, `crossdeckCustomerId` resolved from the server) do *not* change the token.

- **`cd.reset()` (sign-out) wipes the token.** The next user on the same device mints a fresh one — uniqueness-per-purchasing-entity is the property that makes the server-side attribution join correct.

### Anonymous-user / pre-identify purchases

The helper is safe to call from an anonymous session — common when an app shows a paywall before sign-up. On that first anonymous purchase:

- The token is **lazy-minted on the first call**, before any `identify()` has happened. The purchase still gets a stable, Apple-immutable token stamped on it.

- The token is **not derived from the SDK's anonymous identifier**. It's a fresh random UUID, independent of every other Crossdeck identifier. Rotating the anonymous ID does not affect it.

- When the user later signs up and you call `cd.identify("user_123")`, the SDK forwards the persisted token alongside the alias request. The server records the binding `appAccountToken → developerUserId` at that moment, and every past purchase in that chain attaches to `user_123`. No re-purchase, no manual reconciliation.

### Anonymous-only apps (no identify() at all)

Some apps never call `identify()` — there is no login, every user is anonymous for the life of the install. Crossdeck still gives you a complete revenue picture. On **every launch** the SDK sweeps `Transaction.currentEntitlements` (the device's signed, currently-valid receipts) and forwards each one to the backend with the install-stable `appAccountToken` — no `identify()`, no "Restore Purchases" tap, no backend of your own.

- **Attribution to the install.** With no user to name, the server attributes each subscription to the install itself, keyed on its `appAccountToken`. Revenue, renewals, and churn appear on the dashboard from day one — an anonymous-only app is a first-class revenue citizen, not a blind spot.

- **Automatic re-attribution if you add auth later.** The moment that same install *does* call `identify(userId:)` — even months later — every subscription owned by the install's token is folded onto that user automatically. No migration, no re-purchase, nothing stranded on an anonymous record.

- **Never a silent wrong-merge.** If a token is already owned by a *different* identified user (a shared device, an account handover), it is **not** silently reassigned — it surfaces in **Settings → Identity → Conflicts** for you to resolve. Deterministic same-install claims are automatic; contested ones are not.

Access is independent of all of this: a returning subscriber is entitled the instant the app opens, offline, straight from their device's signed receipt — the attribution above is a separate, server-side concern that never gates the paywall.

### Server-side resolution

When Apple's ASSN V2 webhook arrives later carrying the token, the backend looks the binding up first and resolves the customer via the recorded mapping — not via the older implicit assumption that `appAccountToken == developerUserId`. Customers who slipped through with a mismatched token (older SDK install, manual `syncPurchases` without the helper, etc.) surface in **Settings → Identity → Conflicts** as an `Apple unbound token` row for operator review.

Do not roll your own UUID, do not derive from your user ID, do not skip the helper.

Apple's transaction record is permanent. A token that goes stale never recovers — every renewal in that chain carries the wrong token forever, and the subscription orphans silently. The v1.4.x line shipped a derivation-from-`developerUserId` path that caused exactly this trap; v1.5.0 replaces it with the helper. The deprecated `AppAccountTokenDerivation.derive(developerUserId:)` symbol is still in the module to preserve the cross-SDK test oracle, but the auto-track listener and `cd.syncPurchases(...)` no longer call it.

### Errors

- `cd.captureError(_:handled:)` — manual capture.

- `cd.addBreadcrumb(_:)` — append to the ring buffer.

### Consent

- `cd.setConsent(_:)` — toggle analytics + errors channels.

- `cd.setScrubPII(_:)` — toggle PII scrubber.

## Diagnostics & debugging

Pass `defaultDebugLogger()` for human-readable signal routing to Apple's unified logging system at `info` level under the `com.crossdeck.sdk` subsystem:

```
let options = CrossdeckOptions(
    appId: "app_ios_xxx",
    publicKey: "cd_pub_test_…",
    environment: .sandbox,
    debugLogger: defaultDebugLogger()
)
```

View in Console.app or via `log stream`:

```
log stream --predicate 'subsystem == "com.crossdeck.sdk"' --level info
```

### Signal vocabulary

Every internal SDK log goes through a structured `DebugSignal` enum so a consumer can grep for specific events:

Signal Fires when

sdk.configured Client successfully started + ready to accept track/identify. Carries appId, environment, sdkVersion.
sdk.first_event_sent First event of this process landed at the ingest endpoint. One-shot per process lifetime — dashboard onboarding checklist keys off this signal.
sdk.invalid_key publicKey doesn't start with cd_pub_ (or matches no known prefix). Always loud — also surfaced as an error.
sdk.no_identity track() fired without a known developerUserId AND no stored anonymousId (degenerate — usually storage failed).
sdk.entitlement_cache_used isEntitled(...) answered from the local cache without a network round-trip.
sdk.purchase_evidence_sent Successful /purchases/sync POST.
sdk.environment_mismatch Configured environment doesn't match the publicKey prefix. Surfaced at start; SDK refuses to construct.
sdk.sensitive_property_warning A property key looks like PII ( email , password , token , secret , card , phone ). Warning-level — event ships, consumer reviews.
sdk.property_coerced A property value was coerced during sanitisation (Date → ISO, NaN → null, circular → "[circular]", truncated, etc.).
sdk.queue_persisted Queue state successfully written to UserDefaults. Emitted on every enqueue, every successful flush, and every app-background transition.
sdk.queue_restored Queue state successfully rehydrated from UserDefaults on start.
sdk.flush_retry_scheduled Flush hit a retryable failure (5xx / 408 / 429 / network) and is scheduled for retry. Payload: attempt , delay_ms .
sdk.flush_permanent_failure Queue dropped a batch — permanent 4xx OR retry budget exhausted. Always loud; routed to onPermanentFailure when set.
sdk.consent_changed Consent state changed via setConsent(...) .
sdk.consent_denied track() or $error dropped because the relevant consent channel is off. Payload includes channel .
sdk.consent_dnt_applied Do-Not-Track detected (web parity — N/A on iOS but emitted for cross-SDK consistency).
sdk.pii_scrubbed PII scrubber replaced an email or card on the wire.

### Queue stats

```
let stats = await cd.stats()
print("buffered=\(stats.buffered) pending=\(stats.pending) attempts=\(stats.attemptsForPending)")
```

Use for in-app diagnostics screens or to gate a "Send feedback" flow on "queue is fully drained."

## Troubleshooting

### Xcode shows "Status: v1.0.0" in the package picker (or an old README)

- Xcode caches the README aggressively. **File → Packages → Reset Package Caches** forces a fresh fetch from GitHub. After that, **File → Packages → Update To Latest Package Versions** re-resolves and pulls the newest tag matching your Dependency Rule.

- If you're tracking `Branch: main`, you may also need to verify the rule is what you actually want — see the next item.

### My package is set to Branch: main and I can't change it

- **Why this happens:** Xcode's *Add Package Dependencies* dialog falls back to `Branch: main` when no semver tags exist on the repository at the time you added it. If you added Crossdeck before `v1.0.0` was tagged, your project anchored to `main`.

- **Why the obvious fix fails:** reopening *File → Add Package Dependencies…* shows the Dependency Rule dropdown greyed out, with "The project already depends on: crossdeck-swift with rule main" at the bottom. **That dialog is hard-blocked from changing rules on existing dependencies — don't waste time fighting it.**

- **Removing + re-adding usually loops, too:** Xcode's "Recently Used" suggestions auto-reseed the package as soon as you type the URL, with the dropdown still greyed. The only way to change the rule is from the project's Package Dependencies tab.

- **The path that works:** Click your project file (blue Xcode icon, top of the file navigator).

- In the editor pane, select your project under the **PROJECT** column — *not* under TARGETS. The rule editor only lives on the project.

- Click the **Package Dependencies** tab at the top.

- **Double-click** the `crossdeck-swift` row. A small sheet opens with the Dependency Rule editor — this is the only UI in Xcode that can change a rule on an already-added package.

- Change *Branch* to *Up to Next Major Version*, set the version to `1.5.0`, click **Done**.

If double-click doesn't open the sheet, try right-clicking the row → Modify Package Settings (label varies by Xcode version).

- **Bulletproof fallback (no Xcode UI):** Quit Xcode. Open `YourProject.xcodeproj/project.pbxproj` in a text editor. Find the `XCRemoteSwiftPackageReference "crossdeck-swift"` block. Change `requirement = { branch = main; … };` to `requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; };`. Save. Re-open Xcode — it picks up the change and re-resolves.

### Build fails with "no such module 'Crossdeck'"

- The Crossdeck library product wasn't added to your app target. Project settings → your app target → **General** → **Frameworks, Libraries, and Embedded Content** → click `+` → select `Crossdeck` under the `crossdeck-swift` package.

- If the package itself is missing from *Package Dependencies*, you skipped the Add Package step. Repeat the Install flow.

### Events not appearing in the dashboard

- Consent defaults to GRANT for both analytics + errors — you should NOT need to call `setConsent(...)` to start seeing events. If you've explicitly set `initialConsent: .deny` or called `cd.setConsent(...analytics: false)`, that's the culprit. Re-grant with `cd.setConsent(.init(analytics: true, errors: true))`.

- Check that the `publicKey` matches the project you're inspecting in the dashboard. Test keys (`cd_pub_test_…`) and live keys (`cd_pub_live_…`) route to separate event streams.

- Enable `debugLogger: defaultDebugLogger()` in `CrossdeckOptions` and watch for `sdk.queue_persisted` entries in Console.app (or the one-shot `sdk.first_event_sent` on the first successful batch of the process).

- If your launch crashed before the queue flushed, durability kicks in — pending events persist to `UserDefaults` and replay on next launch. Restart the app and check the dashboard again.

### Stale simulator install — ⌘B vs ⌘R

Xcode's **⌘B (Product → Build)** compiles into DerivedData but does *not* install the new binary onto the simulator. **⌘R (Product → Run)** compiles, installs, and launches. If you bumped `@cross-deck/swift`, added a `.crossdeckScreen` or `.crossdeckTap` modifier, and only hit ⌘B to confirm the compile, the simulator is still running the old binary — your new modifiers aren't in the running process and the dashboard sees no `page.viewed` events.

Symptoms: Pages tab shows 0 pages even after adding modifiers; per-person journey shows session events but no `Viewed "..."` lines. Auto-tracked sessions still fire (they use the SDK's process singleton, no SwiftUI environment plumbing) which is why the journey shows *some* activity but not the screens you decorated.

Fix: force-quit Clippi in the simulator (swipe up + flick), then ⌘R from Xcode. Watch for the fresh launch sequence (icon fade-in, splash) rather than a process resume. If you're chasing a stubborn build cache, also run **Product → Clean Build Folder** (⇧⌘K) and quit Xcode entirely.

### "Clicked an element" rows in the per-person journey

Expected — these are taps the auto-track captured but couldn't resolve a label for. Two causes:

- **The CTA hasn't been decorated with [.crossdeckTap("Name")](#tap-labels) yet.** Modern SwiftUI hides Button text from UIView introspection (see [Tap labels](#tap-labels) for the Apple-side mechanics). For funnel-critical buttons, add the modifier and the journey will render *"Clicked 'Generate'"* on the next launch.

- **The tap was on an incidental surface** — a sheet's dismiss handle, a scroll thumb, a decorative chevron, a list-row tap that doesn't drive a funnel step. These genuinely don't need a label; their value is as the engagement *denominator*, not as a CTA. Leave them undecorated.

The presence of `Clicked an element` rows alongside named ones is the correct state — it preserves the engagement signal (someone tapped *something*, the session is alive) without forcing you to label every tappable pixel.

### How long until I see events on the dashboard?

Typical end-to-end is **3–8 seconds** from `cd.track(...)` returning to the event surfacing on Activity / People journey / Pages. The path:

- **SDK batches** — `track()` appends to the in-memory queue; a batch flushes at `batchSize` (default 20) or every `flushIntervalMs` (default 5 000ms), whichever fires first.

- **HTTP POST** — one batch goes over the wire with a per-batch `Idempotency-Key`. Typically

- `401 unauthorized` — publishable key wrong or revoked, or the `environment` in `CrossdeckOptions` doesn't match the key prefix (`cd_pub_live_` requires `.production`; `cd_pub_test_` requires `.sandbox`). Rotate via the dashboard's API Keys screen.

- `422 unprocessable_entity` — payload shape rejected by server validation. The `message` on the `CrossdeckError` handed to `onPermanentFailure` tells you which field.

### isEntitled() returns false after a successful purchase

- Call `cd.syncPurchases(rail: .apple, signedTransactionInfo: ...)` (StoreKit 2 JWS) to POST evidence directly — the server verifies and projects entitlements. Follow it with `await cd.refreshEntitlements()` to hydrate the local cache immediately. Both shipped in v1.0.1; call them in your StoreKit `Transaction.updates` handler right after a successful purchase.

### Strict-concurrency warnings in my app

- The SDK compiles clean under strict concurrency. If your app sees warnings about `Crossdeck`, check that you're holding the instance from a Sendable context (e.g. a SwiftUI `@State` or an actor-isolated property). The client itself is `@unchecked Sendable` — safe to share across actors.

## Versioning & changelog

The Swift SDK follows [SemVer](https://semver.org/). Breaking changes only ship in a major-version bump. Full release notes live in the public repo's [CHANGELOG.md](https://github.com/Crossdeckhq/crossdeck-swift/blob/main/CHANGELOG.md); every released version is tagged on the public repo so SwiftPM consumers can pin to a specific tag or use `.upToNextMajor` resolution. Current tags: `v1.0.0`, `v1.0.1`, `v1.0.2`, `v1.0.3`, `v1.1.0`, `v1.2.0`, `v1.4.4`, `v1.4.5`, `v1.4.6`, `v1.4.7`, `v1.4.8`, `v1.4.9`, `v1.5.0`, `v1.5.1` (latest).

### Latest: v1.4.9 — 2026-05-27

`View.crossdeckTap("Name")` — explicit tap-label bolt-on for SwiftUI buttons whose label the auto-track can't reach on iOS 16+. Apple's Metal text rendering pipeline draws Button text straight to a CALayer with no UILabel intermediary, so the walk-up + descendant search added in v1.4.7 finds nothing on modern SwiftUI. Same shape as `.crossdeckScreen` — one line per important CTA, the button's own action still runs via `.simultaneousGesture`. See the new [Tap labels](#tap-labels) section for the pattern matrix.

### v1.4.8 — 2026-05-27

Rename `enum Environment` → `enum CrossdeckEnvironment`. Structural fix for the SwiftUI symbol collision that v1.4.4's typealias only papered over for consumer code — the SDK author's own `@Environment(\.crossdeck)` inside `CrossdeckScreenModifier` still resolved to the module-scoped enum and failed with `'Environment' cannot be used as an attribute`. Renaming the source enum eliminates the trap structurally: there is no short form in scope, so `@Environment(...)` resolves to SwiftUI's wrapper unambiguously inside the SDK module AND for every consumer. **Breaking** — code using `Environment.production` / `.sandbox` must use `CrossdeckEnvironment.production` / `.sandbox`. Pre-launch, all shipping install snippets already use the qualified form.

### v1.4.7 — 2026-05-26

SwiftUI tap-label auto-capture. Closes the "Clicked an element" gap that buried real CTAs on the dashboard. The `UIWindow.sendEvent` walk-up extended from 4 ancestors to 16 — SwiftUI's button-hosting tree puts the merged accessibility label 8–12 hops above the touched `Text`/`Image`. Plus a descendant-search fallback that descends up to 6 levels into the touched view's subtree for a `UILabel` or accessibility-labeled view (SwiftUI's accessibility-merge model commonly puts the label on a sibling or descendant). Same PII substring filter applied to descendant-found text so a password field's visible text never lands on the wire.

### v1.4.6 — 2026-05-26

SwiftUI hosting-controller denylist. Fixes a leak where the auto-track `viewDidAppear` swizzle was emitting framework class names (`PresentationHostingController`, `NavigationStackHostingController`, `UIKitNavigationController`) as `page.viewed` events — those buried the developer's real screens with meaningless internal names. The substring denylist now catches `*HostingController` across all SwiftUI host variants. Pairs with backend filtering (`isFrameworkHostName`) so existing already-shipped events stop polluting the dashboard without waiting for the consumer to rebuild.

### v1.4.5 — 2026-05-26

SwiftUI screen tracking. Pure-SwiftUI apps now populate the Pages dashboard with one line per screen — `.crossdeckScreen("Name")` on each user-visible destination. See [Screen views](#screen-views) for the five-pattern matrix (NavigationStack, NavigationLink, .sheet, .fullScreenCover, TabView).

- **`View.crossdeckScreen("Name", properties:)`** — new SwiftUI modifier, public. Fires `page.viewed` with `{ screen, title, …properties }` on `.onAppear`. Name truncated to 128 chars (matches the host-side title cap). Properties flow through the same `cd.track` validation as any other event.

- Existing UIKit auto-track is unchanged — `UIViewController.viewDidAppear` still fires `page.viewed` automatically with the class name.

### v1.2.0 — 2026-05-25

Full bank-grade parity with the Web/Node/RN SDKs. v1.1.0 closed the ergonomics gap (non-throwing API); v1.2.0 closes every remaining gap a serious customer would notice — auto-tracking, App Store privacy manifest, performance vitals, mobile lifecycle, and ambient signal modules.

- **Auto-tracking (default-on).** The SDK fires `session.started` / `session.ended` / `element.clicked` automatically across UIKit and SwiftUI, plus `page.viewed` on every UIViewController appearance. SwiftUI screens deliberately hide their destination names from the runtime, so they need one `.crossdeckScreen("Name")` modifier per screen — see [Screen views](#screen-views). Same event vocabulary as Web/Android so a single dashboard query returns rows uniformly. UIControl actions and SwiftUI button taps are both captured (the tap routes through `UIWindow.sendEvent` regardless of UIKit vs SwiftUI). Privacy guardrails baked in: secure text fields, password / card / SSN accessibility labels skipped silently; `cd-noTrack` identifier opt-out. 30-minute idle threshold matches GA4 / Mixpanel convention. Configure via `CrossdeckOptions(autoTrack: .off)` for strict-consent flows.

- **PrivacyInfo.xcprivacy bundled in the SDK.** Apple began enforcing the required-reason API manifest at App Store Connect submit in May 2024. Without one, every embedding app is rejected. Crossdeck now ships `PrivacyInfo.xcprivacy` declaring `NSPrivacyAccessedAPICategoryUserDefaults` (reason CA92.1) and `NSPrivacyAccessedAPICategorySystemBootTime` (reason 35F9.1). Consumer apps inherit the manifest automatically via SPM resource copy.

- **MetricKit performance vitals (opt-in).** `CrossdeckOptions(enablePerformanceMonitoring: true)` subscribes the SDK to MetricKit and fires `perf.metrics` (daily aggregates), `perf.hang`, `perf.cpu_exception`, `perf.disk_write_exception`, and `perf.crash_diagnostic` events. Mirrors the Web SDK's `web-vitals` contract. iOS 14+ / macOS 12+.

- **Proactive network-edge flush.** `NWPathMonitor` watches reachability. On offline → online transitions, the event queue flushes immediately instead of waiting for the next 5-second timer. Closes the latency gap on intermittent connections. ON by default; iOS 12+.

- **Automatic StoreKit 2 purchase tracking (opt-in).** `CrossdeckOptions(automaticPurchaseTracking: true)` installs a `Transaction.updates` consumer. Every signed transaction flows to `/purchases/sync` AND fires `purchase.completed` / `purchase.refunded` / `purchase.unverified` events. Off by default; iOS 15+.

- **Deep-link + push tracking helpers.** `cd.trackDeepLink(url:source:)` extracts UTM + click-id query params (gclid, fbclid, msclkid, ttclid, li_fat_id, twclid) as top-level properties — same acquisition-attribution shape the Web SDK captures. `cd.trackPushReceived(...)` / `cd.trackPushInteraction(...)` for FCM / APNs payloads, never logging the alert body.

- **Lifecycle parity fixes.** Force-quit on iOS now persists pending events (was losing up to one batch). Mac Catalyst Cmd+Q + pure-AppKit terminate now wire to `queue.persistAll()`. watchOS extension lifecycle wired.

- **Strictly additive.** Zero v1.1.0 call-sites change. All new modules are default-OFF where they could be surprising. Update by bumping the SPM rule to `1.2.0` and re-resolving.

### v1.1.0 — 2026-05-25

Fire-and-forget API ergonomics — matches Mixpanel / Amplitude / Sentry / Firebase Analytics iOS conventions. Dogfood feedback flagged that requiring `try?` at every analytics call site was hostile in Swift even though Web/Node/RN's `track()` throws.

- **Non-throwing:** `track`, `identify`, `reset`. No more `try?` at every call site. Empty event name / userId / called-after-stop log a debug warning and the call becomes a no-op — never a trap, in Debug or Release. Invalid input never crashes the host app and never reaches the wire.

- **Still throwing** (legitimate runtime failure modes): `Crossdeck.start(options:)`, `identifyAndWait`, `forget`, `getEntitlements`, `syncPurchases`, `flush`, `heartbeat`.

- **Migration:** soft break. `try? cd.track(...)` still compiles with a "no calls to throwing functions" warning — drop the `try?` to clean up. `try cd.track(...)` inside a `do/catch` compiles but the catch becomes unreachable — drop both. Plain `cd.track(...)` (the v1.1.0 idiom) compiles clean.

- **Cross-SDK consistency:** Web/Node/RN keep throwing signatures because JS uncaught throws propagate to the global error handler without requiring `try` everywhere. The platform contract is "`track` validates input and signals failure for empty name." Swift's signalling is now language-idiomatic (`assertionFailure` + debug log) instead of `throws`.

### v1.0.3 — 2026-05-25

Critical compile-fix release. v1.0.0–v1.0.2 declared `iOS(.v13)` in `Package.swift` but `defaultDebugLogger()` used Apple's modern `Logger` API (iOS 14+). Apps with deployment target below iOS 14 failed to compile.

- **Fixed.** `defaultDebugLogger()` now branches on availability. iOS 14+ uses `Logger`; older OS versions fall back to `os_log` (iOS 10+). Same Console.app filtering surface, identical signal vocabulary.

- Package compiles cleanly against any deployment target ≥ iOS 13 — same floor `Package.swift` has always claimed.

### v1.0.2 — 2026-05-25

Dogfood pass on the v1.0.1 surface. One additive API change closes the biggest first-time-Swift-dev friction point; everything else is documentation polish that ships on cross-deck.com.

- **`Crossdeck.current` — process-singleton accessor.** Returns the most-recently-started client, or `nil` before `start` has succeeded in this process / after the current client's `stop` is called. Thread-safe via `NSLock`; safe to read from any actor or queue. Use it from services, view models, AppDelegate methods, Combine pipelines, and background workers — anywhere outside a SwiftUI view body where `@Environment(\.crossdeck)` isn't an option. Strictly additive: every v1.0.1 caller continues to compile and behave identically.

- **Bank-grade slot discipline.** `stop()` clears the `Crossdeck.current` slot iff the stopped instance is the one currently advertised. Concurrent start+stop sequences on a second client never clobber the first client's slot — important for tests that spin clients up and down in arbitrary order.

### v1.0.1 — 2026-05-25

KPMG-grade audit pass. Closes every contract gap and privacy concern an external bank-grade reviewer would flag against v1.0.0. Public API is additive only — every v1.0.0 caller still compiles.

- **Public sync paywall reads.** `cd.isEntitled(_:)` and `cd.entitlementsForCurrentCustomer()` are now first-class methods on the `Crossdeck` client, backed by NSLock-protected sync mirrors on the underlying actors. Safe to call from a SwiftUI view body or a UIKit tap handler.

- **NSException handler now chains** into Crashlytics / Sentry / Bugsnag instead of overwriting them. v1.0.0 silently broke every co-installed crash reporter; v1.0.1 captures the prior handler at install time and invokes it after our own snapshot.

- **PII scrubber runs on `$error` events.** Every scrubbable field on the wire payload (message, stack symbols, breadcrumb messages + data) is now scrubbed when consent is on.

- **Breadcrumbs attached to `$error` events** as `error.breadcrumbs: [{timestamp_ms, category, level, message, data}]`. Previously collected but dropped before enqueue.

- **`identify(...)` unconditionally clears the entitlement cache**, matching the documented contract. Even identifying with the same id wipes — tiny redundant rebuild is cheaper than a leaked entitlement across an account switch.

- **Errors-consent channel gate.** The error pipeline now honours `consent.errors` independently from `consent.analytics`.

- **Self-request skip on `captureError`** — drops errors whose URL host matches the configured ingest endpoint, closing the feedback loop where a wrapped ingest failure generated another ingest failure.

- **Identify+track race fixed.** Identity is now read synchronously on the caller's thread before the enqueue task spawns, so a track immediately following an identify always observes the new developerUserId.

- **Empty-key validation** on super-properties.

- **+19 new tests** (53 → 72 total). Coverage added for: sync paywall reads from any thread, identify cache clearing under idempotent same-id calls, identify cache clearing across customer switches, `stop()` rejecting subsequent calls, URL-stub HTTP tests for 2xx/400/401/422/500/408/429 classification, `Retry-After` honouring, `Idempotency-Key` + `User-Agent` headers shipped verbatim.

### v1.0.0 — 2026-05-24

Initial release. Bank-grade event ingestion (pending-batch slot, 4xx hard stop, `Retry-After` honoured, durable rehydration). Identity actor with anonymousId persistence + reset regeneration. Customer-scoped entitlement reads. PII scrubber with ` ` / ` ` tokens, recursive walk, NSDictionary cycle detection via object identity. NSException + manual `captureError`. Strict concurrency enabled; every shared-mutable type behind a Swift actor. Zero runtime dependencies.

## Related

- [Web SDK reference](https://cross-deck.com/docs/web-sdk/)

- [Node SDK reference](https://cross-deck.com/docs/node-sdk/)

- [API keys & authentication](https://cross-deck.com/docs/api-keys/)

- [Create a project](https://cross-deck.com/docs/create-a-project/)
