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).
- Available through Swift Package Manager. CocoaPods is not available yet — see Platform support.
1. Install
In Xcode choose File ▸ Add Package Dependencies and paste the repository URL, or declare it in Package.swift:
https://github.com/appunik-retainza/ios-sdk.package(url: "https://github.com/appunik-retainza/ios-sdk.git", from: "1.0.0")Import the module, not the product
Either way, the import is the module name:
import RetainzaSDK2. Start the SDK
In your App or AppDelegate, as early as possible:
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 }
))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. |
| 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).
3. Identify & track
// 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.
// 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 clearsThe 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 AttrValues — 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 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.
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.
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), and push_permission only once RetainzaPush reports it (see 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.
4. Push
Xcode setup the SDK cannot do for you
The SDK cannot edit your project file, and these are silent failures if missed:
- Signing & Capabilities ▸ + Capability ▸ Push Notifications. Without the
aps-environmententitlement iOS never issues a token at all, and nothing reports an error. - Background Modes ▸ Remote notifications, if you send silent pushes.
- Upload your APNs key (
.p8) to Firebase Cloud Messaging, since Retainza sends via FCM. This is a console step, not a code one. - Add Firebase Messaging to the app (
FirebaseMessagingvia SPM, plus yourGoogleService-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(_:).
Code
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:
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.
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.
try? await RetainzaPush.requestAuthorization() // registers with APNs if grantedPermission 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.
5. In-app messages
One mount point, at the root of your view hierarchy:
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.
Navigation
Campaigns route by screen name, not URL — marketers author against your screens, not a deep-link scheme:
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:
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:
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.
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). registerPushand 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().
7. Consent
Off by default, because turning it on silently would drop every existing integration's data.
RetainzaConfig(publicKey: "pk_…", requireConsent: true)With it on nothing leaves the device — not even an identify — until:
await Retainza.shared?.setConsent(analytics: true, marketing: false)Pre-consent calls are dropped, not queued: replaying them afterwards is exactly what consent forbids.
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.
8. Sessions
await Retainza.shared?.startSession() // app became active
await Retainza.shared?.endSession() // app entered the backgroundIn 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.
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. |
| Warning: “handleDeviceToken no longer registers” | Replace that call with Messaging.messaging().apnsToken = token plus handleFCMToken in MessagingDelegate — see 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. |
| 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().
