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.
Install
Section titled “Install”Add the package in Xcode (File → Add Package Dependencies…) or in
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.
Initialize
Section titled “Initialize”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.
Identify
Section titled “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:
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.
Restore identify at app start
Section titled “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
Section titled “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.
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.
What happens before login?
Section titled “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
Section titled “Screens”Screen views are manual:
try await whisperr.screen("Paywall") // sends screen_viewed { screen_name: "Paywall" }Automatic events
Section titled “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) |
app_opened | cold_start, launch_source, first_after_update | the app comes to the foreground (see 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
Section titled “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
Section titled “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:
// 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
Section titled “Errors and crashes”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.
Push notifications
Section titled “Push notifications”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.
Push tokens
Section titled “Push tokens”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-environmentfrom the app’s embedded provisioning profile.developmentgivessandbox.productiongivesproduction. - With no profile, the simulator gives
sandbox. - Else the build decides:
DEBUGgivessandbox, a release build givesproduction. App Store and TestFlight builds have no profile and useproduction.
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").
Token rules
Section titled “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 nextidentify()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 newanonymous_id.
Push permission
Section titled “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.
let status = await Whisperr.requestPushAuthorization() // alert, badge, soundlet quiet = await Whisperr.requestPushAuthorization(provisional: true) // no prompt, quiet deliveryAfter 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.
Push opens
Section titled “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.
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()Push received in the foreground
Section titled “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
Section titled “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:
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.
Logout
Section titled “Logout”await whisperr.reset() // flushes, then clears the current userOpt-out
Section titled “Opt-out”await whisperr.optOut() // clears the queue, sends nothingawait whisperr.optIn() // sends againThe 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?
Section titled “How does the Swift SDK deliver events?”identifyandtrackgo into one ordered queue.identifycalls go toPOST /v1/identify.trackcalls are combined intoPOST /v1/events/batch.- The queue is saved in
UserDefaultsby 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. ARetry-Afteron429or503sets the wait, up to 60 seconds.401and403keep the queue. Other4xxdrop the request. Every drop reachesonError.
See the delivery contract.
Options
Section titled “Options”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 deliveryThe 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
Section titled “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.