Skip to content

Swift SDK

Last updated

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. It installs the SDK and wires your events and push for you.

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

Package.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. Release notes are in the changelog.

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

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:

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.

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:

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. 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 for the limits on addresses from the app. Send push tokens with setPushToken, 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.

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.

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.

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.

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.

// 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:

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.

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.

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.

Screen views are manual:

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

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.

EventPropertiesWhen
app_installednonefirst launch with the SDK
app_updatedprevious_version, previous_buildfirst launch after the app version or build changes
app_crashedcrash_kind, detected_by, crashed_app_version, crashed_session_id, crashed_screen_name, foregroundat launch, when the last run ended while the app was in the foreground (see Errors and crashes)
app_openedcold_start, launch_source, first_after_updatethe app comes to the foreground (see Launch source)
app_backgroundedforeground_ms, session_id, screen_count, unique_screen_count, event_count, error_countthe app goes to the background, with the counts of this foreground visit
app_errorerror_kind, fatal, error_name, fingerprint, screen_name, session_idan uncaught Objective-C exception, or a trackError call
push_permission_changedstatus, previous_status, and from 0.7.0 alerts_enabled, lock_screen, scheduled_summary, time_sensitivethe 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.

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.

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:

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

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.

Whisperr sends push through your own provider account. Connect it in the dashboard first: see Connect a push provider. 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.

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

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.

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").

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

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.

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:

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.

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.

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:

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()

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.

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
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:

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.

await whisperr.reset() // flushes, then clears the current user
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.

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

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.

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.