View Categories

Swift (iOS)

10 min read

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.0.0"),
    .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.0.0.
  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 and profile identifiers straight away.

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

// 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. 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 and the anonymous profile the user browsed under is merged into it, so their pre-login 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. Because the merge is skipped, the push token has to be registered against the new profile explicitly:

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

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

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. Without it the stored token drifts stale and pushes start failing with โ€œdevice token expiredโ€.

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

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

    if client.isRelevaMessage(userInfo: userInfo) {
        client.trackEngagement(userInfo: userInfo, type: .opened)
    }

    completionHandler()
}

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
)

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.