Deep linking, attribution, and smart links for iOS.
Part of the Grovs open-source mobile linking platform.
Quick Start · API Reference · Full Docs
The Grovs iOS SDK provides deep linking, universal linking, link generation, in-app messaging, event and screen tracking, revenue tracking, and attribution for iOS apps built with UIKit or SwiftUI.
Upgrading from 2.x? See the 3.0 upgrade guide.
- Deep linking & universal links — route users to the right in-app screen, even after install
- Smart link generation — create trackable links with metadata, custom redirects, and UTM parameters
- Events & screen tracking — custom events, automatic screen views for UIKit and SwiftUI
- In-app messaging — display messages and announcements from the Grovs dashboard
- Push notifications — receive push campaigns, report opens and follow their links
- Revenue tracking — log StoreKit 2 and custom purchases with automatic attribution
- User identity — attach user IDs and attributes for analytics and segmentation
- Consent gating — collect nothing until the user opts in
- Self-hosting support — point the SDK at your own backend
- iOS 13.0+
- Swift 5.9+ (Xcode 15+)
- In Xcode, go to File → Add Package Dependencies…
- Enter the repository URL:
https://github.com/grovs-io/grovs-iOS.git - Choose Up to Next Major Version starting at
3.1.0 - Add the Grovs library to your app target
Add the pod to your Podfile:
pod 'Grovs', '~> 3.1'Then run:
pod installImport the module and configure the SDK in your AppDelegate:
import Grovs
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Optional: verbose logging while integrating
Grovs.setDebug(level: .info)
Grovs.configure(APIKey: "your-api-key", useTestEnvironment: false, delegate: self) { success in
if success {
print("Grovs SDK is ready")
}
}
// Optional: identify the user (after configure)
Grovs.userIdentifier = "user_id_from_your_app"
Grovs.userAttributes = ["name": "John Doe", "plan": "premium"]
return true
}Automatic screen tracking is on by default; pass autoTrackScreenViews: false to turn it off. For self-hosted backends, pass the baseURL parameter (domain only — the SDK appends the API path):
Grovs.configure(APIKey: "your-api-key", useTestEnvironment: false, baseURL: "https://your-domain.com", delegate: self)func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
Grovs.handleSceneDelegate(openURLContexts: URLContexts)
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
Grovs.handleSceneDelegate(continue: userActivity)
}
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
Grovs.handleSceneDelegate(options: connectionOptions)
}func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
return Grovs.handleAppDelegate(continue: userActivity, restorationHandler: restorationHandler)
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
return Grovs.handleAppDelegate(open: url, options: options)
}Conform to the GrovsDelegate protocol to receive deep link callbacks. The delegate is held weakly, so assign an object that lives as long as your app:
class YourViewController: UIViewController, GrovsDelegate {
override func viewDidLoad() {
super.viewDidLoad()
Grovs.delegate = self
}
func grovsReceivedPayloadFromDeeplink(link: String?, payload: [String: Any]?, tracking: [String: Any]?) {
// Route the user based on payload data
if let screen = payload?["screen"] as? String {
navigateTo(screen)
}
}
}GrovsDelegate also has an optional grovsDidEncounterError(_:message:) for surfacing SDK errors to your own logging.
You can also retrieve past payloads:
// Get the most recent payload
Grovs.lastReceivedPayload { payload in
print("Last payload: \(payload)")
}
// Get all payloads received since app launch
Grovs.allReceivedPayloadsSinceStartup { payloads in
guard let payloads = payloads else { return }
for payload in payloads {
print("Payload: \(payload)")
}
}When a link with copy-to-clipboard enabled is opened in a browser, the preview page copies it to the visitor's clipboard before sending them to the App Store. On the first launch with the SDK, it reads the link and attributes the install deterministically; the deferred link arrives through the same GrovsDelegate callback. This works with no integration. iOS may show its paste confirmation on that launch — it is skipped for projects with no clipboard-enabled link clicks in the last 48 hours and for devices whose clipboard holds no web URL. Denying it simply leaves the install unattributed.
If your project serves links from a custom domain, list it so the SDK recognizes your links (content on any other host is never read or sent):
Grovs.configure(APIKey: "your-api-key", useTestEnvironment: false, clipboardDomains: ["links.yourdomain.com"], delegate: self)Create smart links with metadata, payload data, and tracking parameters:
Grovs.generateLink(
title: "Check out this product",
subtitle: "Limited time offer",
imageURL: "https://example.com/image.jpg",
data: ["productId": "12345", "screen": "product_detail"],
tags: ["promotion", "share"],
trackingCampaign: "spring_sale",
trackingSource: "in_app",
trackingMedium: "share_button"
) { url in
guard let url = url else { return }
print("Generated link: \(url)")
}To override the project's copy-to-clipboard setting for a single link, pass copyToClipboardiOS / copyToClipboardAndroid — nil inherits the project default.
Override where a link sends users on each platform:
let redirects = CustomRedirects(
ios: CustomLinkRedirect(link: "https://example.com/ios-promo"),
android: CustomLinkRedirect(link: "https://example.com/android-promo"),
desktop: CustomLinkRedirect(link: "https://example.com/desktop-promo", openAppIfInstalled: false)
)
Grovs.generateLink(title: "Special offer", data: ["promoId": "summer25"], customRedirects: redirects) { url in
guard let url = url else { return }
print("Generated link: \(url)")
}Present a share sheet after generating a link:
Grovs.generateLink(title: "Share this", data: ["itemId": "abc"]) { url in
guard let url = url else { return }
let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil)
self.present(activityVC, animated: true)
}Track custom events with optional properties and tags:
Grovs.track("purchase", properties: ["item": "sku-42", "price": 19.99], tags: ["checkout"])
Grovs.trackScreenView("Checkout", properties: ["section": "payment"])
Grovs.setGlobalTags(["beta"]) // attached to every later event; nil clearsEvents are written to disk, sent in batches, and retried on failure. Every custom event carries a screen_name property naming the most recently viewed screen.
With autoTrackScreenViews enabled (the default), the SDK tracks UIKit view controllers by class name and SwiftUI screens by view type. SwiftUI screens whose name can't be resolved unambiguously (an AnyView root, for example) are dropped rather than reported under a meaningless name — annotate those:
struct CheckoutView: View {
var body: some View {
VStack { ... }
.grovsScreen() // auto-named from the file: "Checkout"
// or .grovsScreen("Checkout Page")
}
}.grovsScreen() also suppresses automatic tracking for that screen, so it is never counted twice. For UIKit, conform to GrovsScreenTracking to name or skip a controller, or set a global resolver:
Grovs.screenNameProvider = { viewController in
switch viewController {
case is LegalViewController: return .suppress
case is ProductViewController: return .name("Product")
default: return .automatic
}
}Map class names to friendlier dashboard names with Grovs.setScreenAliases(["CheckoutViewController": "Checkout Page"]).
Property values may be strings, numbers, booleans, NSNull, or nested arrays and dictionaries of those. Date, URL, and UUID are coerced to strings; values that can't be represented in JSON are dropped for that key only; properties over 8 KB are dropped together and the event is still sent. Event names must be non-empty and not a reserved system name (install, open, screen_view, …). Tags are capped at 20 per event.
Receive push campaigns sent from the Grovs dashboard or the server API, report opens and follow their links.
1. Add capabilities — In Xcode, add the Push Notifications capability.
2. Upload your APNs key — In Apple Developer → Keys, create a key with APNs enabled. Upload the .p8 file, Key ID, and Team ID in your Grovs dashboard under Settings → Push Notifications.
The Grovs test environment sends through the APNs sandbox, production through production APNs. Builds run from Xcode get sandbox tokens, so configure them with
useTestEnvironment: true; TestFlight and App Store builds withfalse. A mismatch is rejected by APNs (BadDeviceToken) and the token is dropped.
3. Request permission, register, and handle taps:
import UserNotifications
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Configure Grovs first (see Setup); set the delegate before returning so a launching tap is delivered.
UNUserNotificationCenter.current().delegate = self
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }
application.registerForRemoteNotifications()
return true
}
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Grovs.setPushToken(deviceToken: deviceToken)
}
// Reports the open and delivers the push to grovsReceivedPayloadFromDeeplink.
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
Grovs.handleNotificationResponse(response)
completionHandler()
}
// Optional: show pushes that arrive while the app is open.
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
if #available(iOS 14.0, *) {
completionHandler([.banner, .list, .sound])
} else {
completionHandler([.alert, .sound])
}
}
}A tapped Grovs push arrives in grovsReceivedPayloadFromDeeplink like a deep link, and in allReceivedPayloadsSinceStartup. The payload is the push's userInfo without aps: your campaign data keys plus grovs_campaign_id, grovs_link and grovs_image. When the push has a link, the link is resolved and its payload is merged in (the push's keys win).
Grovs.isGrovsNotification(userInfo:) tells Grovs pushes apart from your other providers'. Taps and a token set before configure are held until then; a held tap is dropped if you configure with enabled: false.
4. Images (optional) — Add a Notification Service Extension target, link the GrovsNotificationService product (SPM) or pod, and make its principal class inherit from it:
target 'NotificationService' do
pod 'GrovsNotificationService', '~> 3.1'
endimport GrovsNotificationService
class NotificationService: GrovsNotificationServiceExtension {}If you already have an extension, call GrovsNotificationContent.attachImage(to:session:completion:) from it instead. Pass it the content your other code has already changed; the image is added to its attachments. Keep the task it returns and cancel it when time runs out; the completion then runs once, with the content minus the image, so don't deliver separately:
class NotificationService: UNNotificationServiceExtension {
private var download: URLSessionTask?
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
let content = request.content // or the content your other code has already changed
download = GrovsNotificationContent.attachImage(to: content, completion: contentHandler)
}
override func serviceExtensionTimeWillExpire() {
download?.cancel()
}
}Use JPEG, PNG or GIF images; notifications show no other format, and the push arrives without one. The extension ships through SPM and CocoaPods only, not in the Grovs.xcframework download.
Remote pushes reach the iOS Simulator on Apple silicon Macs (Xcode 14+) with sandbox tokens;
xcrun simctl pushdelivers local test payloads.
Revenue tracking is currently in beta.
- Enable revenue tracking in the Grovs dashboard under Settings → Revenue Tracking
- Configure App Store Server Notifications in App Store Connect — set the production and sandbox URLs shown in the Grovs dashboard under Developers → iOS Setup → Revenue
import StoreKit
let result = try await Product.purchase(...)
if case .success(let verification) = result,
case .verified(let transaction) = verification {
Grovs.logInAppPurchase(transactionID: transaction.id) { success in
if success {
Task { await transaction.finish() }
}
}
}Requires iOS 15+. The SDK automatically extracts price, currency, and product info. Duplicates are filtered.
Grovs.logCustomPurchase(
type: .buy,
priceInCents: 999, // $9.99
currency: "USD",
productID: "premium_monthly"
) { success in
// Revenue event recorded
}Use .cancel and .refund transaction types for cancellations and refunds. For App Store purchases, these are detected automatically via App Store Server Notifications.
The SDK collects nothing while it is disabled — no authentication, no device resolution, no event or purchase delivery. To start a launch disabled, pass your stored consent to configure; calling setSDK(enabled: false) afterwards is too late, because configuring starts the first round of collection:
Grovs.configure(APIKey: "your-api-key", useTestEnvironment: false, enabled: hasUserConsent, delegate: self)
// Later, as consent changes. Enabling authenticates and sends what was held.
Grovs.setSDK(enabled: true)The flag is not persisted — pass your stored value to configure every launch. A disabled configure reports false to its completion without authenticating; treat that as "not started", not as a failure.
The SDK ships a PrivacyInfo.xcprivacy privacy manifest, reads no advertising identifier, and does not require App Tracking Transparency. See Privacy & Consent for what is collected and what to declare in App Store Connect.
| Property | Type | Description |
|---|---|---|
delegate |
GrovsDelegate? |
Receives deep link payloads and SDK errors (held weakly) |
userIdentifier |
String? |
User ID shown in dashboard and reports |
userAttributes |
[String: Any]? |
User attributes for analytics |
pushToken |
String? |
APNs device token (hex) for push notifications; setPushToken(deviceToken:) sets it from Data |
screenNameProvider |
((UIViewController) -> GrovsScreenName)? |
Resolver for automatically tracked screen names |
| Method | Description |
|---|---|
configure(...) |
Initialize the SDK — API key, environment, baseURL, autoTrackScreenViews, clipboardDomains, enabled, delegate |
setSDK(enabled:) |
Enable or disable all collection |
setDebug(level:) |
Set logging level (.info, .warn, .error) |
generateLink(...) |
Generate a smart link |
lastReceivedPayload(...) |
Get the last deep link payload |
allReceivedPayloadsSinceStartup(...) |
Get all payloads since launch |
linkDetails(path:...) |
Get details for a link path |
track(_:properties:tags:) |
Track a custom event |
trackScreenView(_:properties:) |
Track a screen view |
setGlobalTags(_:) |
Tags attached to every later event |
setScreenAliases(_:) |
Friendly dashboard names for view controller classes |
displayMessagesViewController(...) |
Deprecated: the messages inbox was removed; only calls its completion |
numberOfUnreadMessages(...) |
Deprecated: the messages inbox was removed; always yields 0 |
handleNotificationResponse(_:) |
Report a push campaign tap and deliver it as a deep link |
handleNotificationOpen(userInfo:) |
The same, for hosts that only have the payload of a tapped push; never call it on delivery (didReceiveRemoteNotification), which would count every push as opened |
setPushToken(deviceToken:) |
Pass the APNs token from didRegisterForRemoteNotificationsWithDeviceToken |
isGrovsNotification(userInfo:) |
Whether a push came from Grovs |
logInAppPurchase(transactionID:...) |
Log a StoreKit 2 purchase |
logCustomPurchase(...) |
Log a custom purchase — type, price in cents, currency, product ID, optional date |
Full API reference: docs.grovs.io/docs/sdk/ios/api-reference
A demo project is available at grovs-io/grovs-ios-example-app.
- Custom URL Scheme — configure deep link URL schemes
- Associated Domains — set up universal links
- Apple App Prefix — find your Team ID
- Bundle Identifier — find your bundle ID
Full documentation at docs.grovs.io.
For technical support and inquiries, contact [email protected].
See LICENSE for details.