Skip to content

Available · Swift Package Manager

Retainza iOS SDK

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

View as Markdownllms.txt

Build this with AI

Integrate the Retainza iOS SDK into this Swift app: add it with Swift Package Manager, start it with my public key, identify the user on login, track the key events, register for push through Firebase Messaging and show in-app messages.

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

Step 1: Swift Package Manager (primary)

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

Import the module, not the product

Either way, the import is the module name:

Swift
import RetainzaSDK

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

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

OptionDefaultWhy you would change it
baseURLhttps://api.retainza.com/v1Self-hosted backend. Must end in /v1.
storageUserDefaultsStorage()Supply your own store, or InMemoryStorage() in tests. nil makes the queue, session and install id memory-only.
defaultAttributesnilA closure whose attributes ride along on every identify.
allowRuntimeBaseURLsame as debugLets setBaseURL(_:) / a stored override redirect traffic. Keep off in release builds.
requireConsentfalseGDPR/CCPA — see Consent.
sessionTimeoutMs30 minHow long in the background ends a session.
maxQueuedEvents100Offline queue capacity.
debugfalseLogs every request. Off in production — when off the SDK prints nothing.
loggernilRoute 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

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

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.

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:

  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(_:).

Code

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.

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.

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.

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.

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

8. Sessions

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.

9. Troubleshooting

SymptomCause
import RetainzaImport the module: import RetainzaSDK. Retainza is only the SPM product name.
Init throws .configurationYou passed an sk_ key. Only pk_ keys belong in an app.
No push token, no errorMissing Push Notifications capability (aps-environment).
Device registered, pushes never arriveAn 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 registersNo user yet (.missingUserId). Call registerPush again after identify.
Pushes arrive, opens are zerohandleOpen 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 nowhereNo onNavigate handler. Check the logs.
Events vanish offlineYou passed storage: nil, so the queue is memory-only.
No sessions in the dashboardstartSession() / endSession() never called — see Sessions.
Nothing sends at allrequireConsent 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().

Developer docsDashboard

Popular