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-sdk11.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 #
- File โ Add Package Dependenciesโฆ
- Enter
https://github.com/Releva-ai/sdk-swift.git, with the dependency rule โUp to Next Major Versionโ from5.0.0. - Add the
RelevaSDKproduct to your app target. - Add
https://github.com/firebase/firebase-ios-sdk.gitas a second package and add itsFirebaseMessagingandFirebaseCoreproducts to your app target. - For rich push notifications, add the
RelevaNotificationExtensionproduct 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 #
- In Xcode, select your app target โ Signing & Capabilities.
- Add โPush Notificationsโ.
- 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 theScrollView, not to theScrollViewitself; from UIKit callclient.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:
- Call the Product Create / Update API whenever your catalogue changes.
- Implement the Periodical Product Sync as a fallback, so a missed update cannot leave Releva stale.
- Call the Cart Paid API when a customer places an order, and the Refund API when one is refunded.
- Call the Subscribe and Unsubscribe APIs when a user changes their marketing preferences.
- 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.