<!-- Source: https://developer.retainza.com/ios/ · Markdown: https://developer.retainza.com/ios.md -->

Available · Swift Package Manager

# Retainza iOS SDK

Identify users, track events, receive push and show in-app messages from a native Swift app.

<!-- section: overview -->

## Overview

`RetainzaSDK` is the native Swift client for Retainza. It identifies users, tracks events, registers the device for push and displays in-app messages — talking only to Retainza's public-key-scoped ingest API.

- Requires **iOS 15+** and **Xcode 15+**.
- The SDK itself has no third-party dependencies. Receiving push also needs Firebase Messaging in your app (see [Push](https://developer.retainza.com/ios/#push)).
- Available through Swift Package Manager. CocoaPods is not available yet — see [Platform support](https://developer.retainza.com/#platforms).

> **Note:** The behaviour described here is shared with the [React Native SDK](https://developer.retainza.com/react-native/) — attribute rules, the offline queue, sessions and consent are identical by design, and that guide is the longer prose version. This page covers what is different on iOS.

<!-- section: install -->

## 1. Install

**Step 1:** Swift Package Manager (primary)

In Xcode choose **File ▸ Add Package Dependencies** and paste the repository URL, or declare it in `Package.swift`:

**Xcode**

```
https://github.com/appunik-retainza/ios-sdk
```

**Package.swift**

```swift
.package(url: "https://github.com/appunik-retainza/ios-sdk.git", from: "1.0.0")
```

> **Warning:** **CocoaPods is not available yet.** The SDK ships a podspec, but `RetainzaSDK` is not published to the CocoaPods trunk, so `pod 'RetainzaSDK'` will not resolve. Use Swift Package Manager.

### Import the module, not the product

Either way, the import is the **module** name:

```swift
import RetainzaSDK
```

> **Warning:** With SPM you add the *product* `Retainza` to your target, but Swift imports modules, not products, and the module is `RetainzaSDK`. `import Retainza` will not compile.

<!-- section: initialize -->

## 2. Start the SDK

In your `App` or `AppDelegate`, as early as possible:

```swift
import RetainzaSDK

try? Retainza.start(with: RetainzaConfig(
    // Debug builds: pk_test_… (Sandbox). Release builds: pk_live_… (Production).
    // Set it per build configuration with an xcconfig.
    publicKey: "pk_test_…",
    userProvider: { AuthStore.shared.userId }
))
```

> **Warning:** **The key decides the environment.** A build carrying `pk_live_…` reaches your real users, whatever else it is configured with. Note also that the `aps-environment` entitlement is **Apple's** push environment and is independent of the Retainza one — both must be set, consistently. See [Keys & base URL](https://developer.retainza.com/#concepts).

`publicKey` **must** start with `pk_`. The client refuses to initialise with a secret (`sk_`) key — those reach the campaign and send APIs, and anyone can extract a string from a shipped IPA.

`userProvider` is called on every request, so the SDK always uses your current user without you re-telling it. It is authoritative: the id cached by `identify` is only a fallback.

### Configuration worth knowing about

| Option | Default | Why you would change it |
| --- | --- | --- |
| `baseURL` | `https://api.retainza.com/v1` | Self-hosted backend. Must end in /v1. |
| `storage` | `UserDefaultsStorage()` | Supply your own store, or `InMemoryStorage()` in tests. `nil` makes the queue, session and install id memory-only. |
| `defaultAttributes` | `nil` | A closure whose attributes ride along on every `identify`. |
| `allowRuntimeBaseURL` | same as `debug` | Lets `setBaseURL(_:)` / a stored override redirect traffic. Keep off in release builds. |
| `requireConsent` | `false` | GDPR/CCPA — see [Consent](https://developer.retainza.com/ios/#consent). |
| `sessionTimeoutMs` | 30 min | How long in the background ends a session. |
| `maxQueuedEvents` | 100 | Offline queue capacity. |
| `debug` | `false` | Logs every request. **Off in production** — when off the SDK prints nothing. |
| `logger` | `nil` | Route SDK warnings into your crash reporter. Receives warn/error even when `debug` is off. |

With the default storage the SDK keeps its state in `UserDefaults.standard` under `engage.queue.v1`, `engage.session.v1`, `engage.install.v1`, `engage.push.v1` and `engage.baseUrl.v1`. Consent and opt-out are **not** stored (see [Consent](https://developer.retainza.com/ios/#consent)).

<!-- section: tracking -->

## 3. Identify & track

```swift
// Full profile upsert. Auto attributes ride along on every call.
try await Retainza.shared?.identify(
    "user-123",
    attributes: ["plan": "pro", "seats": 4],
    profile: RetainzaProfile(firstName: "Ada", email: "ada@example.com")
)

// A patch — sends only what you pass.
try await Retainza.shared?.setAttributes(["plan": "enterprise"])

// An event.
try await Retainza.shared?.track("order_placed", properties: ["total": 42.5])
```

Attribute values are `String`, `Double`, `Bool` or `nil` — nothing nested. Literals work directly: `["plan": "pro", "seats": 4, "trial": true]`.

### Collections — one-to-many data

When a user can have several of something (challenges, badges, saved searches), that is a *collection*, not a pile of numbered attributes.

```swift
// Id only, when the list is identifiers and nothing else:
try await Retainza.shared?.addToCollection("challenges", id: "lagos_10k")

// With fields, when a campaign has to FIND things in the list:
try await Retainza.shared?.addToCollection(
    "challenges",
    item: CollectionItem("lagos_10k", ["status": "done", "progress": 100])
)

try await Retainza.shared?.removeFromCollection("challenges", id: "lagos_10k")
try await Retainza.shared?.setCollection("challenges", items: ["a", "b"])  // replaces; empty clears
```

The `id` is **yours** and is the merge key: writing the same id twice updates that item instead of appending, which is what makes a write replayed from the offline queue safe. Fields you omit are preserved.

Item fields are flat `AttrValue`s — same four cases as attributes, no nesting. `updated_at` is stamped by the server. A `CollectionItem` is `ExpressibleByStringLiteral`, so `["a", "b"]` is a list of id-only items. The [React Native guide](https://developer.retainza.com/react-native/) covers the caps and a worked “challenge finished → send a message” example.

### On logout, call reset()

Without it the next person on the device inherits the previous user's identity, queued events, session counters — and the previous user's **push notifications**, because nothing else takes a token off a profile.

```swift
await Retainza.shared?.reset()
```

`reset()` unregisters this device from the profile being left (best-effort: an offline logout still succeeds). It deliberately keeps the **install id**, which describes the device rather than the person — it is what tells the server a rotated push token is the same handset, so clearing it would make every sign-in look like a new phone.

> **Note:** `userProvider` is yours: if it still returns the old id after logout, the SDK will use it.

### Attributes the SDK sets for you

`platform`, `sdk_version`, `app_version`, `language`, `country`, `tz`, `device_model`, `device_manufacturer`, `os_name`, `os_version`, `session_count`, `first_session_at`, `last_session_at`, `session_duration_total`, `push_permission`.

The `session_*` values only exist once you drive sessions (see [Sessions](https://developer.retainza.com/ios/#sessions)), and `push_permission` only once `RetainzaPush` reports it (see [Push](https://developer.retainza.com/ios/#push)).

Precedence is **SDK-collected → `config.defaultAttributes` → the values you pass at the call site**. Your value always wins: if you set `country` from a billing address, the SDK will not overwrite it.

> **Tip:** Event names are matched by exact string in segments, so pick a convention and keep it — lowercase snake_case past-tense (`order_placed`) is what the docs and dashboard assume.

<!-- section: push -->

## 4. Push

### Xcode setup the SDK cannot do for you

The SDK cannot edit your project file, and these are silent failures if missed:

1. **Signing & Capabilities ▸ + Capability ▸ Push Notifications.** Without the `aps-environment` entitlement iOS never issues a token at all, and nothing reports an error.
2. **Background Modes ▸ Remote notifications**, if you send silent pushes.
3. Upload your **APNs key (`.p8`)** to Firebase Cloud Messaging, since Retainza sends via FCM. This is a console step, not a code one.
4. **Add Firebase Messaging to the app** (`FirebaseMessaging` via SPM, plus your `GoogleService-Info.plist`). See the next section.

### The token Retainza needs is the FCM token, not the APNs token

Retainza sends every push through **FCM** (`firebase-admin`, addressed by registration token). FCM cannot deliver to a raw APNs device token: sent one, it answers `invalid-registration-token` and the server prunes the device as dead.

So in an app using Retainza push, let Firebase turn the APNs token into an FCM token, and register **that** with `RetainzaPush.handleFCMToken(_:)`.

> **Warning:** `RetainzaPush.handleDeviceToken(_:)` is **deprecated**. It used to register the raw APNs token, which gave you a device in the dashboard that never received a push; it now only logs a warning and registers nothing. If you still call it, replace it with the code below.

### Code

```swift title="AppDelegate.swift"
import FirebaseCore
import FirebaseMessaging
import RetainzaSDK

// In didFinishLaunchingWithOptions:
FirebaseApp.configure()
Messaging.messaging().delegate = self
UNUserNotificationCenter.current().delegate = self
Task { await RetainzaPush.handleLaunchOptions(launchOptions) }
Task {
    // Returning users who already answered the prompt: fetch a fresh token and
    // refresh push_permission. First-time prompting happens elsewhere (below).
    if await RetainzaPush.currentPermission() != .undetermined {
        await RetainzaPush.registerForRemoteNotifications()
    }
    await RetainzaPush.syncPermission()
}

func application(_: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
    // Hand the APNs token to Firebase — it produces the FCM token below.
    Messaging.messaging().apnsToken = token
}

// MessagingDelegate — fires on first launch and whenever FCM rotates the token.
func messaging(_: Messaging, didReceiveRegistrationToken fcmToken: String?) {
    guard let fcmToken else { return }
    Task { try? await RetainzaPush.handleFCMToken(fcmToken) }
}

func application(_: UIApplication,
                 didFailToRegisterForRemoteNotificationsWithError error: Error) {
    RetainzaPush.handleRegistrationFailure(error)
}
```

And in your `UNUserNotificationCenterDelegate`:

```swift
func userNotificationCenter(_: UNUserNotificationCenter,
                            willPresent notification: UNNotification) async
                            -> UNNotificationPresentationOptions {
    await RetainzaPush.handleForeground(notification)
    return [.banner, .sound]
}

func userNotificationCenter(_: UNUserNotificationCenter,
                            didReceive response: UNNotificationResponse) async {
    await RetainzaPush.handleOpen(response)
}
```

Each handler returns `true` if the notification was a Retainza one, so an app running a second push SDK can tell whether to keep processing.

For silent pushes, call `await RetainzaPush.handleBackground(userInfo)` from `application(_:didReceiveRemoteNotification:fetchCompletionHandler:)`.

`handleFCMToken` (a wrapper over `registerPush(token:)`) needs a user: it resolves the id from `userProvider` or the last `identify`, and throws `.missingUserId` otherwise. If the token can arrive before sign-in, call it again after `identify` with the current token from `try await Messaging.messaging().token()`. An unchanged token is re-sent at most once every 24 hours; pass `force: true` to override that.

> **Tip:** `handleLaunchOptions` is easy to skip and worth not skipping: a tap that *launches* the app never reaches the delegate, so without it every cold-start open goes uncounted.

### When to prompt

`requestAuthorization()` triggers the system prompt. iOS allows **one per install** — if the user denies it, nothing can ask again and only a trip to Settings recovers it. Call it when your app has explained why, not at launch. The SDK deliberately never calls it for you.

```swift
try? await RetainzaPush.requestAuthorization()  // registers with APNs if granted
```

Permission can also be revoked in Settings at any time with no callback, so call `await RetainzaPush.syncPermission()` on launch (the launch snippet above does). Otherwise Retainza keeps targeting a device that stopped being reachable months ago.

<!-- section: inapp -->

## 5. In-app messages

One mount point, at the root of your view hierarchy:

```swift
WindowGroup {
    ContentView()
        .engageInApp()
}
```

That is the whole integration (it polls every 15 s; pass `.engageInApp(intervalMs:)` to change that). It owns the poll loop, shows one message at a time, and sends the `shown` / `clicked` / `dismissed` acks every campaign's funnel is built from.

> **Warning:** Apply it **once**. Messages arrive on a timer, not on navigation, so a per-screen mount would poll N times over and race to show the same campaign twice.

### Navigation

Campaigns route by *screen name*, not URL — marketers author against your screens, not a deep-link scheme:

```swift
await Retainza.shared?.onNavigate { route in
    Task { @MainActor in router.go(to: route.screen, params: route.kv ?? [:]) }
}
```

Without this a tap still acks correctly but goes nowhere. The SDK logs a warning naming the missing call. The handler (like `onSelfHandledInApp`) is called off the main thread, so hop to the main actor before touching UI.

### Not now, thanks

Some screens must not be interrupted:

```swift
await Retainza.shared?.setBlockedScreenPrefixes(["checkout", "video"])
await Retainza.shared?.setCurrentScreen("checkout/payment")
```

A blocked message stays **pending** rather than being consumed, so the campaign shows on the next poll once the user moves on.

### Rendering it yourself

For a `selfhandled` campaign the SDK draws nothing and hands you the payload. In exchange **you own the acks** — skipping them does not break rendering, it silently empties that campaign's funnel:

```swift
await Retainza.shared?.onSelfHandledInApp { message in
    myPresenter.show(message.content) {
        message.markShown()
    } onTap: {
        message.markClicked(nil)
    } onClose: {
        message.markDismissed()
    }
}
```

`markClicked(nil)` also navigates to the message's own route through your `onNavigate` handler; pass a `Route` to go somewhere else.

> **Note:** A `selfhandled` message with no handler registered stays pending rather than disappearing — a half-finished integration should look like a campaign that has not shown yet, not one that vanished.

<!-- section: offline -->

## 6. Offline queue

`track`, `identify`, `setAttributes` and the collection calls are queued when a call fails with a network error or a 5xx, and retried with exponential backoff (1 s doubling to a 5-minute ceiling).

- **4xx is never queued.** A malformed body or a revoked key fails the same way forever, and queueing it would block every event behind it.
- **Ordering is preserved.** A flush stops at the first failure rather than skipping ahead.
- **Timestamps are stamped at call time**, so an event that waits an hour still records when it actually happened.
- **The oldest is dropped** past `maxQueuedEvents` (100). A device offline for a week should send this morning's events, not last week's.
- The queue survives restarts (it lives in `storage`).
- `registerPush` and the acks are **never** queued — a stale token is worse than a missing one, and a replayed ack lands in the wrong reporting window.

Inspect it with `queueSize()`; drain it manually with `flushQueue()`.

<!-- section: consent -->

## 7. Consent

Off by default, because turning it on silently would drop every existing integration's data.

```swift
RetainzaConfig(publicKey: "pk_…", requireConsent: true)
```

With it on **nothing** leaves the device — not even an identify — until:

```swift
await Retainza.shared?.setConsent(analytics: true, marketing: false)
```

Pre-consent calls are **dropped, not queued**: replaying them afterwards is exactly what consent forbids.

> **Warning:** Consent is **not persisted** by the SDK: with `requireConsent` on, call `setConsent` on every launch from your own stored answer, or nothing is sent.

`marketing` does not gate transport. It is stored as `consent_marketing` (and `analytics` as `consent_analytics`) so campaigns can target or exclude on it — someone can accept product analytics and refuse promotions, and collapsing the two makes that unrepresentable.

`optOut()` is a harder switch: it stops everything and clears the queue. It is deliberately **not persisted** — an opt-out the SDK remembers is one your app cannot see, and your app owns the consent UI. Re-assert it each launch from your own stored preference; `await Retainza.shared?.optIn()` lifts it.

<!-- section: sessions -->

## 8. Sessions

> **Warning:** **Not automatic on iOS** — the SDK does not observe the app lifecycle, so you call these from your foreground/background hooks. Without them no `session_start` events and no `session_*` attributes are ever sent.

```swift
await Retainza.shared?.startSession()   // app became active
await Retainza.shared?.endSession()     // app entered the background
```

In SwiftUI, drive them from `scenePhase` (`.active` → `startSession`, `.background` → `endSession`). A new session starts on foreground unless the app returns within `sessionTimeoutMs` (30 minutes) of the last `endSession`; then it **resumes** the same session rather than inflating the count — so a user who checks a push and comes back is not counted twice.

<!-- section: troubleshooting -->

## 9. Troubleshooting

| Symptom | Cause |
| --- | --- |
| `import Retainza` | Import the module: `import RetainzaSDK`. `Retainza` is only the SPM product name. |
| Init throws `.configuration` | You passed an `sk_` key. Only `pk_` keys belong in an app. |
| No push token, no error | Missing Push Notifications capability (`aps-environment`). |
| Device registered, pushes never arrive | An older build registered the raw APNs token. Register the FCM token with `RetainzaPush.handleFCMToken(_:)` — see [Push](https://developer.retainza.com/ios/#push). |
| Warning: “handleDeviceToken no longer registers” | Replace that call with `Messaging.messaging().apnsToken = token` plus `handleFCMToken` in `MessagingDelegate` — see [Push](https://developer.retainza.com/ios/#push). |
| Token never registers | No user yet (`.missingUserId`). Call `registerPush` again after `identify`. |
| Pushes arrive, opens are zero | `handleOpen` not wired, or `handleLaunchOptions` missing for cold starts. |
| In-app messages never appear | `.engageInApp()` not mounted, the current screen is blocked, or there is no user id yet. |
| Tapping a message goes nowhere | No `onNavigate` handler. Check the logs. |
| Events vanish offline | You passed `storage: nil`, so the queue is memory-only. |
| No sessions in the dashboard | `startSession()` / `endSession()` never called — see [Sessions](https://developer.retainza.com/ios/#sessions). |
| Nothing sends at all | `requireConsent` is on and `setConsent(analytics: true)` was not called this launch. |

Turn on `debug: true` in a dev build to log every request, poll and swallowed error. Check connectivity with `await Retainza.shared?.testConnection()`.
