View Categories

Swift (iOS)

This article explains how to perform a full integration of an iOS app with Releva, using the Releva Swift SDK.

Requirements #

  • iOS 15.0 or later
  • Swift 5.7 language mode or later
  • Firebase, for push notifications. The SDK accepts firebase-ios-sdk 11.15.0 up to (but not including) 13.0.0, so both Firebase 11 and 12 resolve. Your Xcode version has to satisfy whichever one you land on — Firebase raises its toolchain floor over time, including within a major, so pinning to "11.15.0"..<"12.0.0" is a supported way to stay on an older Xcode.

Install the SDK #

Swift Package Manager is the only supported distribution channel. The package exposes two products: RelevaSDK for your app target, and RelevaNotificationExtension for a Notification Service Extension if you want rich push notifications.

Your app target also needs FirebaseMessaging and FirebaseCore declared explicitly. Firebase is resolved as a dependency of the SDK, but Swift Package Manager only lets a target use products from packages your own manifest declares — and the push setup below calls Messaging.messaging() from your code.

In a Package.swift manifest #

dependencies: [
    .package(url: "https://github.com/Releva-ai/sdk-swift.git", from: "5.2.1"),
    .package(url: "https://github.com/firebase/firebase-ios-sdk.git", "11.15.0"..<"13.0.0")
]
.target(
    name: "YourApp",
    dependencies: [
        .product(name: "RelevaSDK", package: "sdk-swift"),
        .product(name: "FirebaseMessaging", package: "firebase-ios-sdk"),
        .product(name: "FirebaseCore", package: "firebase-ios-sdk")
    ]
)

In Xcode #

  1. File → Add Package Dependencies…
  2. Enter https://github.com/Releva-ai/sdk-swift.git, with the dependency rule “Up to Next Major Version” from 5.2.1.
  3. Add the RelevaSDK product to your app target.
  4. Add https://github.com/firebase/firebase-ios-sdk.git as a second package and add its FirebaseMessaging and FirebaseCore products to your app target.
  5. For rich push notifications, add the RelevaNotificationExtension product to your Notification Service Extension target.

The SDK does not set Firebase up for you. You still need a GoogleService-Info.plist in your app target and a FirebaseApp.configure() call during launch, from the standard Firebase iOS setup.

Initialize the Releva Client #

Create the client during app launch and set the device identifier straight away. Set the profile identifier too if the user is logged in.

import RelevaSDK

// realm - use "" unless instructed otherwise by your account manager
// accessToken - from Releva's admin panel, under Settings
let client = RelevaClient(
    realm: "",
    accessToken: "<yourAccessToken>",
    config: RelevaConfig.full()
)

client.setDeviceId(UIDevice.current.identifierForVendor?.uuidString ?? "")

// If the user is logged in: the id you use internally to identify this user
client.setProfileId("<profileId>")

RelevaClient is @MainActor-isolated. Methods that give you a result or an error back are async throws; the rest are fire-and-forget, and several of those still reach the network from a task the SDK starts itself.

Identify the visitor #

The SDK identifies a user solely by the profileId you set. Until you set one, the visitor is anonymous and identified by the device id — they are still tracked and can still receive push notifications. Contact details and other profile attributes are never sent from the app — send those from your backend through the Create / Update Profile API.

On login, set the profile id normally. If a previous profile id was set on this device, that profile is merged into the user’s account, so their earlier behaviour is kept:

client.setProfileId(loggedInUserId)

On logout, generate a new anonymous id and pass true as the second argument to skip that merge — otherwise the next person using the device inherits the previous user’s profile:

let newAnonymousProfileId = UUID().uuidString
client.setProfileId(newAnonymousProfileId, true)

You do not need to re-register the push token after a login or logout, as long as you set pushTokenProvider (see Push notifications below). setProfileId then moves the device’s push token to the new profile, so a logged-out user’s notifications stop reaching the device and the new profile’s start to. If that fails — for example, the user logged in while offline — the SDK retries on the next launch or foreground. Without a pushTokenProvider, call registerPushToken yourself after every setProfileId.

Keeping one client and calling setProfileId on it is the simplest approach. If your app builds a new client on login or logout instead, shut the old one down first — otherwise it stays alive and keeps re-registering the push token under the previous profile on every foreground:

oldClient.shutdown()
let client = RelevaClient(realm: realm, accessToken: accessToken, config: config)

Push notifications #

Enable the capabilities #

  1. In Xcode, select your app target → Signing & Capabilities.
  2. Add “Push Notifications”.
  3. Add “Background Modes” and tick “Remote notifications”.

Register for notifications #

Request permission, then register the token and enable engagement tracking. Set pushTokenProvider as well: FCM rotates tokens silently, and that closure is what lets the SDK re-fetch and re-upload the current token on every launch and foreground, and move it to a new profile when you call setProfileId. Without it the stored token drifts stale and pushes start failing with “device token expired”.

The token can be registered before the user logs in: an anonymous device is registered under its device id, and the token follows the user to their profile when you call setProfileId.

import UserNotifications
import FirebaseMessaging

UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
    if granted {
        DispatchQueue.main.async {
            UIApplication.shared.registerForRemoteNotifications()
        }
    }
}

// In AppDelegate
func application(_ application: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    // Only needed if your Firebase project has no APNs Authentication Key configured:
    // Messaging.messaging().apnsToken = deviceToken

    client.pushTokenProvider = { completion in
        Messaging.messaging().token { token, _ in
            completion(token)
        }
    }

    Task {
        do {
            let fcmToken = try await Messaging.messaging().token()
            try await client.registerPushToken(fcmToken, deviceType: .ios)
            client.enablePushEngagementTracking()
        } catch {
            print("Failed to register push token: \(error)")
        }
    }
}

// Re-register whenever FCM rotates the token at runtime.
// Assign Messaging.messaging().delegate = self during launch, or this never fires.
extension AppDelegate: MessagingDelegate {
    func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
        guard let fcmToken = fcmToken else { return }
        Task { try? await client.registerPushToken(fcmToken, deviceType: .ios) }
    }
}

Keeping your own notification delegate #

enablePushEngagementTracking() makes the SDK the notification centre’s delegate, tracks Releva notifications (delivered, opened) and routes their taps. It remembers the delegate your app had installed at that moment and forwards to it everything it does not handle itself — so set your delegate before calling enablePushEngagementTracking(), usually in application(_:didFinishLaunchingWithOptions:):

UNUserNotificationCenter.current().delegate = self   // first
// ... later, once the token is registered:
client.enablePushEngagementTracking()                // second

What your delegate still receives:

CallbackReleva notificationYour own notification
willPresent (app in foreground)Handled by the SDK: shown as a banner and tracked as delivered. Not forwarded.Forwarded. Your answer decides how it is shown.
didReceive (tap)Tracked and routed by the SDK, then forwarded.Forwarded.
openSettingsFor (Settings deep link)Forwarded.Forwarded.

Because Releva taps are forwarded too, and the SDK has already tracked them as opened, do not call trackEngagement for them yourself — every tap would be counted twice. A delegate that handles taps generically can skip Releva’s with isRelevaMessage:

func userNotificationCenter(_ center: UNUserNotificationCenter,
                            didReceive response: UNNotificationResponse,
                            withCompletionHandler completionHandler: @escaping () -> Void) {
    let userInfo = response.notification.request.content.userInfo

    if !client.isRelevaMessage(userInfo: userInfo) {
        // Your own push: route it. Releva taps are already tracked and routed by the SDK.
    }

    completionHandler()
}

You only need to call trackEngagement yourself if you do not call enablePushEngagementTracking() at all.

Rich notifications #

For images and custom action buttons, add a Notification Service Extension (File → New → Target → Notification Service Extension), add the RelevaNotificationExtension product to it, and replace the generated NotificationService.swift with:

import UserNotifications
import RelevaNotificationExtension

class NotificationService: RelevaNotificationServiceExtension {
    // That is all. The SDK handles the rich notification processing.
}

Navigating from a notification #

When a user taps a notification, the SDK posts a NotificationCenter notification and your app performs the navigation. Observe the ones you support:

NotificationCenter.default.addObserver(
    forName: Notification.Name("RelevaNavigateToScreen"), object: nil, queue: .main
) { notification in
    guard let screen = notification.userInfo?["screen"] as? String else { return }
    let parameters = notification.userInfo?["parsedParameters"] as? [String: Any]

    // `screen` is the free-form value configured in the Releva dashboard
    switch screen {
    case "cart":
        break // navigate to the cart
    case "product_details":
        let productId = parameters?["productId"] as? String
        break // navigate to that product
    default:
        break // home, or handle unknown screens
    }
}

NotificationCenter.default.addObserver(
    forName: Notification.Name("RelevaNavigateToURL"), object: nil, queue: .main
) { notification in
    guard let url = notification.userInfo?["url"] as? URL else { return }
    // A deep link with your own scheme, e.g. myapp://product/123
}

NotificationCenter.default.addObserver(
    forName: Notification.Name("RelevaNavigateToInbox"), object: nil, queue: .main
) { notification in
    let parameters = notification.userInfo?["parsedParameters"] as? [String: Any]
    let inboxMessageId = parameters?["inboxMessageId"]
    // Navigate to the inbox, optionally opening that message
}

External https:// links are opened in Safari by the SDK directly — only your own custom schemes post RelevaNavigateToURL.

Track what the visitor is doing #

Screen views, product views, search, checkout and recommendations all go through one call: build a PushRequest with the fluent builder, then hand it to client.push(...).

Every screen that should record a page view, or receive banners and stories, needs its own page token from the Pages section of the Releva admin. A screen view sent without a token, or with one the backend does not know, is accepted but records no page view and returns no banners or stories — nothing is rejected, so a missing token is easy to miss.

// Screen view
let request = PushRequest()
    .screenView("<yourPageToken>")
    .pageProductIds(["product-1", "product-2"])
    .pageCategories(["electronics", "phones"])
    .locale("en_US")
    .currency("USD")

do {
    let response = try await client.push(request)
    for recommender in response.recommenders {
        print("Recommender: \(recommender.name)")
        for product in recommender.response {
            print("- \(product.name): \(product.price)")
        }
    }
} catch {
    print("Error: \(error)")
}

// Product view
let product = ViewedProduct(id: "product-123")
    .withStringField(key: "brand", values: ["Apple"])

try await client.push(
    PushRequest().screenView("<productPageToken>").productView(product)
)

// Search
try await client.push(
    PushRequest()
        .screenView("<searchPageToken>")
        .search("iPhone")
        .pageProductIds(["product-1", "product-2", "product-3"])
)

PushRequest is a value type: every builder returns a new copy rather than changing the one you called it on, so a part-built request can be safely reused as the base for several pushes. That also means calling a builder as a bare statement throws the edit away — the compiler warns about it.

let base = PushRequest().locale("en_US").currency("USD")

let home = base.screenView("<homeToken>")            // base is unchanged
let search = base.screenView("<searchToken>").search("iPhone")

The SDK sends no page URL, so URL-based targeting rules on banners and stories do not apply on iOS. Target by page token or by segment instead.

Cart and wishlist #

Both are stateful: set them on the client and the SDK syncs the changes for you.

let product1 = CartProduct(id: "sku-123", price: 29.99, quantity: 2)
let product2 = CartProduct(id: "sku-456", price: 49.99, quantity: 1)

client.setCart(Cart.active([product1, product2]))

client.setWishlist([
    WishlistProduct(id: "product-1"),
    WishlistProduct(id: "product-2")
])

// Checkout. The user is identified by the profileId you already set.
let orderedCart = Cart.paid([product1, product2], orderId: "order-789")
try await client.trackCheckoutSuccess(orderedCart: orderedCart, screenToken: "<checkoutToken>")

Custom events #

let customFields = CustomFields()
    .withStringField(key: "color", values: ["red"])
    .withNumericField(key: "discountPercent", values: [15])
    .withDateField(key: "promoEndsAt", values: [Date()])

let event = CustomEvent(action: "selectedColor")
    .withProduct(id: "product-123", quantity: 1)
    .withTag("promo")
    .withCustomFields(customFields)

try await client.trackCustomEvent(event)

Date fields take native Date values and are serialized to ISO-8601 for you.

Filtering #

let complexFilter = NestedFilter.and(
    SimpleFilter.priceRange(minPrice: 100, maxPrice: 500),
    SimpleFilter.brand("Apple")
)

try await client.push(
    PushRequest().screenView("<categoryPageToken>").pageFilter(complexFilter)
)

On-screen content #

Banners, stories, NPS surveys and the App Inbox all arrive in the response to a push and are configured in the Releva dashboard. Add the ones you want.

HomeView()
    .bannerDisplay(client: client, targetSelector: "#home-content") { url in
        handleDeepLink(url)
    }
    .storyDisplay(client: client) { url in
        handleDeepLink(url)
    }

// NPS on the root view
ContentView()
    .npsDisplay(onSubmit: { token, score, comment in
        Task { try? await client.submitNpsResponse(token: token, score: score, comment: comment) }
    })

client.setAppVersion("1.2.3")   // lets the server target surveys by app version

// App Inbox, after the profile id is set
client.initializeInbox()

Two things to know about triggers:

  • Banners reset on every screen view. Navigating back to a screen and tracking it again re-evaluates the triggers, so a banner can show again — the same behaviour as on the web.
  • Scroll triggers need your help. The SDK cannot see your scroll view. In SwiftUI attach .relevaScrollTracking(client) to the content of the ScrollView, not to the ScrollView itself; from UIKit call client.reportScrollPercentage(percent) with a value from 0 to 100. Both require push notifications to be enabled in the config — that is what creates the banner and story managers — and are otherwise silent no-ops.

Leave-intent triggers are a web-only feature and never fire on mobile. Impressions, clicks and dismissals are tracked automatically.

Configuration options #

Start from a preset, or build your own:

let config = RelevaConfig.full()          // everything on (default)
let config = RelevaConfig.trackingOnly()  // no push notifications
let config = RelevaConfig.pushOnly()      // no tracking

let config = RelevaConfig(
    enableTracking: true,
    enableScreenTracking: true,
    enablePushNotifications: true,
    enableAnalytics: true,
    enableDebugLogging: true,
    requestTimeoutInterval: 30.0,
    maxRetryAttempts: 3,
    engagementBatchSize: 10,
    engagementBatchInterval: 30.0
)

Upgrading from 5.2.0 or earlier #

  • Remove any trackEngagement(userInfo:type: .opened) call from your didReceive. Since 5.2.1 the SDK forwards Releva taps to your delegate after tracking them itself, so keeping that call counts every tap twice.
  • Set your own UNUserNotificationCenter delegate before calling enablePushEngagementTracking(). Earlier versions dropped your delegate’s callbacks — taps on your own notifications, your notifications in the foreground and the Settings deep link — and 5.2.1 forwards them only to the delegate that was installed at that moment.
  • With pushTokenProvider set, you can remove any code that re-registers the push token after a login or logout. setProfileId now moves the token, including on a first login from an anonymous device.
  • Push tokens registered before the user logs in are now accepted: an anonymous device is registered under its device id instead of being rejected.

Backend calls you still need #

The app covers what the user does. Your catalogue, your orders and your consent changes still have to reach Releva from your backend:

  1. Call the Product Create / Update API whenever your catalogue changes.
  2. Implement the Periodical Product Sync as a fallback, so a missed update cannot leave Releva stale.
  3. Call the Cart Paid API when a customer places an order, and the Refund API when one is refunded.
  4. Call the Subscribe and Unsubscribe APIs when a user changes their marketing preferences.
  5. Call the Profile Register API when a new user registers.

All set! #

Your app is integrated. Explore the API Specification for the server-side calls that let you send custom product and profile data, which can then be used in personalization.