# Swift SDK

> Install the Whisperr Swift package to identify users, track events and handle APNs or FCM push in iOS and other Apple apps.

The Whisperr Swift package sends identify calls, product events and push tokens from iOS and other Apple apps to Whisperr. Use this page to install it, identify users, track events and wire APNs or FCM push.

Using a coding agent? [Connect it to the Whisperr MCP server](/agents/). It installs the SDK and wires your events and push for you.

## Install

Add the package in Xcode (**File → Add Package Dependencies…**) or in `Package.swift`:

`Package.swift`

```swift
.package(url: "https://github.com/WhisperrAI/whisperr-swift.git", from: "0.7.0")
```

Then add the `Whisperr` product to your app target. The package supports iOS 13, macOS 12, tvOS 13 and watchOS 6 or later. It is listed on the [Swift Package Index](https://swiftpackageindex.com/WhisperrAI/whisperr-swift). Release notes are in the [changelog](https://github.com/WhisperrAI/whisperr-swift/blob/main/CHANGELOG.md).

## Initialize

Call `Whisperr.initialize` once at startup. Create an **App** key (`wpk_…`, the publishable key) in the Whisperr dashboard under **Settings → API keys**.

```swift
import Whisperr


await Whisperr.initialize(apiKey: "wpk_your_publishable_key")


// Later, read the shared client:
guard let whisperr = await Whisperr.shared else { return }
```

Or construct a client yourself for explicit lifetimes and dependency injection:

```swift
let whisperr = WhisperrClient(apiKey: "wpk_your_publishable_key")
```

The examples below use `whisperr` for the client. The push helpers on `Whisperr` (`Whisperr.setPushToken`, `Whisperr.handleNotificationResponse`, …) use the shared client. They are synchronous, never throw, and wait for `initialize` when you call them early. The exception is `Whisperr.requestPushAuthorization()`: it is `async` and returns `nil` before `initialize` finishes.

## Identify

Call `identify` after login and on every app start when a user is already signed in (session restore). This also covers users who signed in before you added the SDK: if your app has a stored user id at start, call `identify` with it. Whisperr merges traits on the server.

Send only the data that messages need. Use `first_name` for a greeting. Do not send a last name, full name, email address or phone number as a trait. Send the email address as a channel:

```swift
try await whisperr.identify(
    user.id,
    traits: ["first_name": .string(user.firstName), "plan": "pro"],
    channels: [
        .email(user.email, optedIn: user.emailOptedOut ? false : nil)
    ]
)
```

The Swift SDK uses the publishable key, so Whisperr ignores `verified` from it. Pass the user’s choice as `optedIn` when the app knows it.

What Whisperr does with the address depends on your answer to **Does your signup get permission to email users?** With **Yes**, the address is opted in unless the app sends `optedIn: false`. With **Per user**, only when the app sends `optedIn: true`. With **No**, or when **Require a server key** is on, Whisperr stores it as pending until your backend confirms it with a [server SDK](/concepts/consent/#confirm-consent-with-identify). `optedIn: false` always records an opt-out. An earlier opt-out, including the unsubscribe link and bounce or complaint suppression, is kept. A later identify from the app never opts the address back in.

App-side consent needs the Swift `Whisperr` package 0.6.0 or later. Older versions cannot set it: Whisperr stores the address as pending, whatever your answer. Older versions also send `opted_in: true` on the `email` shortcut by default. See [Contact consent](/concepts/consent/) for the limits on addresses from the app. Send push tokens with [`setPushToken`](#push-notifications), not as a channel.

Change a user’s existing email address or phone number only from your backend with the server key. Do not send a changed address from the app. Whisperr answers `403` (`contact_change_requires_server_key`) to that identify. The Swift SDK treats every `403` like a rejected key: it reports an `auth` error, pauses delivery and keeps the identify at the head of the queue, so later events wait behind it. A `403` from identify can mean this, not only a bad key.

`identify()` also sends `timezone` (`TimeZone.current.identifier`, an IANA name) and `locale` (`Locale.current` as a BCP 47 tag) as traits. Whisperr uses them for quiet hours and message language. Values you pass in `traits` win.

### Restore identify at app start

At start, when the Swift SDK has a stored user, it identifies the user again with the time zone, locale and app language. It waits 5 seconds first, and sends nothing extra when your app identifies a user in that time or the server got the same values in the last 24 hours. The SDK cannot know a user who signed in before you added the SDK, so still call `identify()` on session restore.

### App language

When your app has its own language setting, tell the SDK. Whisperr writes messages in this language. Use a BCP 47 tag (`de`, `pt-BR`; `pt_BR` also works). This is the language the user picked in your app, not the device locale.

```swift
Whisperr.setAppLanguage("pt-BR")          // or: await whisperr.setAppLanguage("pt-BR")
```

The SDK stores the value and sends it at once, also before login. Every later `identify()` carries it as `traits.app_language`. A `traits["app_language"]` you pass wins. `setAppLanguage(nil)` clears the stored value.

## Track

Record product events. The SDK buffers them and sends them in batches. The timestamp is taken at call time, so events recorded offline keep their real time.

```swift
// An example name. Use your app's own action.
try await whisperr.track("playlist_created", properties: ["song_count": 12])
```

`track()` uses the user from the most recent `identify()`. Pass `userID:` for events outside a signed-in session:

```swift
try await whisperr.track("invite_accepted", userID: "user_123")
```

Event names must be lowercase `snake_case`. The SDK drops other names before they enter the queue and reports them to `onError`. See [Event design](/concepts/events/).

Do not send personal data, such as an email address, phone number or name, in event properties. Whisperr drops property keys named like personal data. See [Track events](/api/events/).

### What happens before login?

`track()` works before `identify()`. The SDK sends the event under a saved random `anonymous_id` (a UUID). The next `identify()` carries the same id, so Whisperr moves those events to the user. `reset()` starts a new anonymous id.

### Screens

Screen views are manual:

```swift
try await whisperr.screen("Paywall")  // sends screen_viewed { screen_name: "Paywall" }
```

## Automatic events

On iOS, iPadOS, tvOS, visionOS and Mac Catalyst the Swift SDK sends these events. You write no code. App extensions, macOS and watchOS send no lifecycle events.

| Event                     | Properties                                                                                                         | When                                                                                                                   |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `app_installed`           | none                                                                                                               | first launch with the SDK                                                                                              |
| `app_updated`             | `previous_version`, `previous_build`                                                                               | first launch after the app version or build changes                                                                    |
| `app_crashed`             | `crash_kind`, `detected_by`, `crashed_app_version`, `crashed_session_id`, `crashed_screen_name`, `foreground`      | at launch, when the last run ended while the app was in the foreground (see [Errors and crashes](#errors-and-crashes)) |
| `app_opened`              | `cold_start`, `launch_source`, `first_after_update`                                                                | the app comes to the foreground (see [Launch source](#launch-source))                                                  |
| `app_backgrounded`        | `foreground_ms`, `session_id`, `screen_count`, `unique_screen_count`, `event_count`, `error_count`                 | the app goes to the background, with the counts of this foreground visit                                               |
| `app_error`               | `error_kind`, `fatal`, `error_name`, `fingerprint`, `screen_name`, `session_id`                                    | an uncaught Objective-C exception, or a `trackError` call                                                              |
| `push_permission_changed` | `status`, `previous_status`, and from 0.7.0 `alerts_enabled`, `lock_screen`, `scheduled_summary`, `time_sensitive` | the app comes to the foreground and the notification permission or one of these settings changed                       |

Each one, and also `screen_viewed`, `push_opened` and `push_received`, carries `app_version`, `app_build`, `os_name`, `os_version`, `platform`, `sdk_name` (`whisperr-swift`), `sdk_version`, `locale` and `timezone`. Every event, also your own, carries `app_version`, `app_build`, `platform`, `sdk_name` and `sdk_version` in its `context`.

Turn the events off with `WhisperrOptions(automaticEvents: false)`. When the app goes to the background, the SDK flushes the queue inside a background task.

From version 0.7.0, the SDK also sends sessions, the launch source, and errors and crashes, as described below. `push_permission_changed` then also carries the notification settings the device exposes: `alerts_enabled` and `lock_screen`, and on iOS 15 or later `scheduled_summary` and `time_sensitive`.

### Sessions

Every event also carries `context.session_id` (`sess_` and a UUID). Each app launch starts a new session, and so does the first event after 30 minutes without one. `app_backgrounded` repeats the id and counts what happened in the foreground visit that ends: `screen_viewed` events (`screen_count`), the distinct screen names (`unique_screen_count`), your own `track()` events (`event_count`) and `app_error` occurrences (`error_count`, including the ones the caps below drop). `foreground_ms` uses the monotonic clock, so a change of the device time does not affect it.

### Launch source

`app_opened` carries `launch_source`: what brought the user into the app. `push` when the user tapped a Whisperr notification (reported through `handleNotificationResponse`, with its `whisperr_message_id`), `deep_link` or `universal_link` for a link, `widget` or `shortcut` for a link with `wsrc=widget` or `wsrc=shortcut` in its query, `email` for a link with a `wmid` parameter, and `direct` otherwise. A push or link counts when it arrives within 1.5 seconds of the move to the foreground and before the next flush. Report links with one line:

```swift
// SwiftUI
.onOpenURL { Whisperr.handleOpenURL($0) }
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { Whisperr.handleUserActivity($0) }


// UIKit: application(_:open:options:) / scene(_:openURLContexts:)
Whisperr.handleOpenURL(url)
// application(_:continue:restorationHandler:) / scene(_:continue:)
Whisperr.handleUserActivity(userActivity)
```

For a link the SDK keeps only the path, with every segment that is all digits, a UUID, or 16 characters or longer replaced by `:id` (`link_route`), the host of an http(s) link (`link_host`) and `utm_source`, `utm_medium` and `utm_campaign`. It drops the rest of the query and the fragment. The first `app_opened` after `app_updated` also carries `first_after_update: true`.

### Errors and crashes

```swift
do {
    try await loadCart()
} catch {
    await whisperr.trackError(error)                                   // error_kind: handled
    await whisperr.trackError(error, screen: "Checkout", kind: .network)
}
```

`trackError` sends `app_error` with the error’s type name (`CartLoadError`; for an `NSError`, its domain and code), never its message, stack or URL, and a `fingerprint` that groups identical errors. The SDK also reports uncaught Objective-C exceptions as `app_error` with `error_kind: native` and `fatal: true`. It installs its handler with `NSSetUncaughtExceptionHandler` and still calls the handler that was installed before it (for example a crash reporter). Swift runtime traps and signals are not caught. Turn the automatic capture off with `WhisperrOptions(captureErrors: false)`; `trackError` still works. At most one `app_error` per fingerprint and 20 per session are sent, and they never trigger a flush on their own.

When the app ends while it is in the foreground (a crash, a watchdog kill, or running out of memory), the next launch sends `app_crashed` before its `app_opened`: `crash_kind: abnormal_exit`, `detected_by: unclean_shutdown`, or `crash_kind: crash`, `detected_by: native_exception` after an uncaught exception. Debug builds never send `app_crashed`, because stopping the app from the debugger looks the same. These events are diagnostics data for your [privacy answers](/privacy/sdk-data/).

## Push notifications

Whisperr sends push through your own provider account. Connect it in the dashboard first: see [Connect a push provider](/integrations/push-providers/). To send to raw device tokens, connect Apple Push Notification service.

The Swift SDK does not bundle a push library. It sends the device token, reports the notification permission, and reports opens with the deep link. An optional extension product adds the image to a rich push.

### Push tokens

APNs (no Firebase): pass the `Data` token from the delegate callback.

```swift
func application(
    _ application: UIApplication,
    didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
    Whisperr.setPushToken(deviceToken)
}
```

The SDK sends the token as hex with `kind: "apns"`, the platform, your bundle id (`client_app_id`) and the APNs environment (`push_env`). A token works in one environment only. The SDK finds it by itself:

- It reads `aps-environment` from the app’s embedded provisioning profile. `development` gives `sandbox`. `production` gives `production`.
- With no profile, the simulator gives `sandbox`.
- Else the build decides: `DEBUG` gives `sandbox`, a release build gives `production`. App Store and TestFlight builds have no profile and use `production`.

To set it yourself: `Whisperr.setPushToken(deviceToken, environment: .production)`.

Firebase Cloud Messaging: pass the FCM registration token and your Firebase project id. Connect Firebase Cloud Messaging in Whisperr. The SDK does not depend on Firebase.

```swift
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
    if let fcmToken {
        Whisperr.setPushToken(fcmToken: fcmToken, firebaseProjectID: FirebaseApp.app()?.options.projectID)
    }
}
```

OneSignal subscription ids: `Whisperr.setPushToken(oneSignalSubscriptionID: id, oneSignalAppID: "your-onesignal-app-id")`.

#### Token rules

- Call it on every launch. The SDK sends the token only when the token or one of its fields changed, or when it last sent it 7 days ago or more, also after an app restart.
- **Before login**, the SDK sends the token at once under the device’s `anonymous_id`. The next `identify()` moves it to the user.
- **Token rotation:** the SDK opts the previous token out and the new one in. Tokens on the user’s other devices are not touched.
- **After `reset()` (logout)**, the SDK opts the token out for the old user and registers it under the new `anonymous_id`.

### Push permission

The Swift SDK sends `push_permission_changed` when the notification permission changed. By default it reads the permission each time the app comes to the foreground, without a prompt. `denied` opts this device’s token out and holds it. When the status is `authorized` or `provisional` again, the SDK opts the token back in.

To ask the user, let the SDK show the prompt. It reports the answer at once. Then call `registerForRemoteNotifications()` to get the token.

```swift
let status = await Whisperr.requestPushAuthorization()                 // alert, badge, sound
let quiet = await Whisperr.requestPushAuthorization(provisional: true) // no prompt, quiet delivery
```

After your own prompt, call `Whisperr.refreshPushPermission()`.

Apple’s App Review guideline 4.5.4 asks for consent before promotional pushes (this is not legal advice). Show a line at the permission prompt that says notifications may include offers and reminders. Give users an in-app toggle to turn them off. If you add the toggle, turn off the automatic check so it does not turn push back on:

```swift
await Whisperr.initialize(
    apiKey: "wpk_your_publishable_key",
    options: WhisperrOptions(automaticPushPermission: false)
)


// Toggle off: opts the push token out and holds it.
Whisperr.pushPermissionChanged(.denied)


// Toggle on, and on each move to the foreground while it is on:
Whisperr.refreshPushPermission()
```

See [Contact consent](/concepts/consent/#push-notifications).

### Push opens

Call `handleNotificationResponse` when the user taps a notification. For a Whisperr notification it sends `push_opened` once per message, with `action_id` and `cold_start`, so Whisperr learns which messages work. It returns the deep link (`whisperr_deep_link`, or `deep_link`). It returns `nil`, and sends nothing, for notifications from other senders and for a dismissal.

```swift
func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    didReceive response: UNNotificationResponse,
    withCompletionHandler completionHandler: @escaping () -> Void
) {
    if let url = Whisperr.handleNotificationResponse(response) {
        open(url) // your router; check the URL first
    }
    completionHandler()
}
```

Set the `UNUserNotificationCenter` delegate before `application(_:didFinishLaunchingWithOptions:)` returns. If you do not, pass the launch options to `Whisperr.handleLaunchOptions(launchOptions)`.

**Cold start.** When a tap starts the app, the delegate can run before your UI exists. The SDK keeps the link until you take it with `Whisperr.consumePendingDeepLink()`, and posts `Whisperr.deepLinkNotification` when a link arrives. Use one of the two ways, not both: route the return value, or take the pending link.

With your own `WhisperrClient` and Swift 6 strict concurrency, build the `Sendable` payload in the delegate. `WhisperrPushPayload(response:)` returns `nil` for a notification from another sender and for a dismissal:

```swift
if let payload = WhisperrPushPayload(response: response) {
    Task {
        let url = await whisperr.handleNotification(payload) // the deep link, or nil
        // Route to `url` on the main actor.
    }
}
completionHandler()
```

### Push received in the foreground

Call `Whisperr.handleWillPresent(notification)` in `userNotificationCenter(_:willPresent:withCompletionHandler:)`. For a Whisperr notification it sends `push_received` once per message. Receipt in the background is not sent.

### Rich push images

Add a Notification Service Extension target to your app. Link the `WhisperrNotificationServiceExtension` product to that target only. Then make its principal class a subclass:

`NotificationService.swift`

```swift
import WhisperrNotificationServiceExtension


final class NotificationService: WhisperrNotificationService {}
```

The extension only adds images. The push needs `"mutable-content": 1` in `aps` and an `https` image URL in `whisperr_image_url` (`image_url` and FCM `fcm_options.image` work too). The extension downloads the image (20 seconds at most, 10 MB at most, JPEG, PNG or GIF) and attaches it. If the download fails or is too slow, the notification shows without the image.

Already have an extension? Call the helper from your own `didReceive`:

```swift
WhisperrRichPush.attachImage(to: mutableContent) { content in
    contentHandler(content)
}
```

The extension product does not depend on `Whisperr` and sends no data to Whisperr. It only downloads the image URL that the push carries.

## Logout

```swift
await whisperr.reset()  // flushes, then clears the current user
```

## Opt-out

```swift
await whisperr.optOut()  // clears the queue, sends nothing
await whisperr.optIn()   // sends again
```

The SDK saves the choice on the device and keeps it across `reset()`. `isOptedOut` reads it. `optOut()` also opts this device’s push token out, so Whisperr stops sending push to it. Data already sent stays on the server. After `optIn()`, the next `setPushToken` registers the token again.

## How does the Swift SDK deliver events?

- `identify` and `track` go into one ordered queue. `identify` calls go to `POST /v1/identify`. `track` calls are combined into `POST /v1/events/batch`.
- The queue is saved in `UserDefaults` by default and survives app restarts.
- Each event carries a stable `$message_id`, so a retry does not create a duplicate.
- `429`, `5xx`, timeouts and network errors retry with backoff. A `Retry-After` on `429` or `503` sets the wait, up to 60 seconds. `401` and `403` keep the queue. Other `4xx` drop the request. Every drop reaches `onError`.

See the [delivery contract](/api/delivery/).

## Options

```swift
let whisperr = WhisperrClient(
    apiKey: "wpk_your_publishable_key",
    options: WhisperrOptions(
        flushInterval: 15,       // seconds
        flushAt: 20,
        maxBatchSize: 500,       // backend hard cap
        maxQueueSize: 1_000,     // drops oldest beyond this
        maxRetries: 6,
        enablePersistence: true,
        debug: false,
        automaticEvents: true,
        automaticPushPermission: true,
        captureErrors: true,     // 0.7.0+: report uncaught exceptions as app_error
        onError: { error in
            print("whisperr:", error.type.rawValue, error.message)
        }
    )
)


await whisperr.flush()  // force delivery
```

The values shown are the defaults. For tests, pass `persistence: InMemoryWhisperrPersistence()` to `WhisperrClient`, or your own `WhisperrPersistence` for custom storage.

## A note on the API key

Use the publishable key (`wpk_…`) in your app. It ships in the app binary and can only send data for your app. Never put a server key (`wrk_…`) in app code: it can change a user’s email address and confirm consent, so it stays on your server.

## Common questions

### Which Apple platforms does the Whisperr Swift SDK support?

iOS 13, macOS 12, tvOS 13 and watchOS 6 or later, through Swift Package Manager. Automatic app events run on iOS, iPadOS, tvOS, visionOS and Mac Catalyst. App extensions, macOS and watchOS send no lifecycle events.

### How does the Swift SDK know the APNs environment?

It reads aps-environment from the embedded provisioning profile. Without a profile, the simulator gives sandbox, a DEBUG build gives sandbox and a release build gives production. Pass environment: to setPushToken to set it yourself.

### Does the Swift SDK need Firebase?

No. It sends raw APNs tokens itself. For FCM, pass the token and the Firebase project id to Whisperr.setPushToken(fcmToken:firebaseProjectID:).

### Does the Swift SDK ship a privacy manifest?

Yes. The Whisperr product ships PrivacyInfo.xcprivacy with no tracking and the data types User ID, Device ID and Product Interaction. The rich push extension product ships its own manifest with no collected data.

Source: https://docs.whisperr.net/sdks/swift/
