Docs
grovs.io Start for free

Push notifications

Receive Grovs push campaigns, report opens, follow their links and show campaign images.

Requires iOS SDK 3.1.0

The SDK reports push campaign opens and hands every tapped Grovs push to your app like a deep link. Campaigns come from the dashboard, a journey or the server API. To create them, see Push campaigns.

Tap handling, setPushToken(deviceToken:) and campaign images need iOS SDK 3.1 or later.

1. Add the Push Notifications capability

In Xcode, select your app target, open Signing & Capabilities, click + Capability and add Push Notifications. Grovs pushes are regular alerts, so skip the Remote notifications background mode.

2. Upload your APNs key to Grovs

  1. In your Apple Developer account, create a key with Apple Push Notifications service (APNs) enabled.
  2. Download the .p8 file. Note the Key ID and your Team ID.
  3. In the Grovs dashboard, open SDK Setup → iOS. In the Push Notifications step, upload the .p8 file and enter the Key ID and Team ID.

A .p12 certificate doesn't work. The Key ID must come from the same team as the Team ID.

Test sends through the APNs sandbox, production through production APNs. Xcode builds get sandbox tokens, so use useTestEnvironment: true; use false for TestFlight and App Store builds. APNs rejects a token from the other environment (BadDeviceToken) and the token is dropped.

3. Register, pass the token and handle taps

Swift
import UserNotifications
 
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
 
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Configure Grovs first (see Quick Start); 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])
        }
    }
}

setPushToken(deviceToken:) converts the token to the hex form Grovs expects. A token set before configure is held until the SDK is configured.

Only a tap on the notification counts as an open. Dismissing it or tapping a custom action button doesn't.

What your app receives

A tapped Grovs push arrives in grovsReceivedPayloadFromDeeplink(link:payload:tracking:) and in allReceivedPayloadsSinceStartup, like any deep link:

  • Payload: the push's userInfo without aps. That's your campaign's custom data keys plus grovs_campaign_id, and grovs_link and grovs_image when set.
  • Campaign with a link: the SDK resolves it like any Grovs link. link is the resolved link. The payload is the link's data merged with the push's keys; on a clash the push's value wins.
  • Campaign without a link: link is nil and the payload is the push's data.
Swift
func grovsReceivedPayloadFromDeeplink(link: String?, payload: [String: Any]?, tracking: [String: Any]?) {
    if let screen = payload?["screen"] as? String {
        // route to the screen your campaign or link points at
    }
}

A tap on a push with a Grovs link counts once, as a campaign open (push_open). The link resolves with source push, so it isn't also a link open and later events aren't attributed to the link.

A push is a Grovs push when its payload has a non-empty grovs_campaign_id. Since 3.1, a push with only grovs_notification_id (used by the removed messages inbox) isn't a Grovs push.

Pushes from other providers

The SDK doesn't take over UNUserNotificationCenter.delegate or swizzle anything, so it works alongside Firebase, OneSignal or your own pushes. handleNotificationResponse returns false and does nothing for pushes Grovs didn't send. Call it first and pass the rest on:

Swift
if !Grovs.handleNotificationResponse(response) {
    // not a Grovs push: hand it to your other provider
}

Grovs.isGrovsNotification(userInfo:) checks without handling the push. With only the payload, call Grovs.handleNotificationOpen(userInfo:).

Delivery guarantees

SituationWhat happens
Before configureThe tap is held and handled once configure runs. With enabled: false, it's dropped.
Before authenticationThe open is sent once the SDK authenticates, even if a later attempt is the one that succeeds.
Network failureRetried at the next foreground, up to 3 attempts. Grovs counts one open per device, so retries never double-count.
App restartUnreported opens (failed, in flight or awaiting authentication) are saved per API key and environment, then sent after the next launch authenticates.
No consentWhile the SDK is disabled, taps deliver nothing and no opens are reported. Saved opens survive a launch without consent and are dropped when consent is withdrawn.

Call handleNotificationOpen(userInfo:) only for a tap. Calling it from application(_:didReceiveRemoteNotification:fetchCompletionHandler:) or userNotificationCenter(_:willPresent:withCompletionHandler:) counts every delivery as an open.

4. Campaign images (optional)

Campaign images need a Notification Service Extension. Grovs marks a push mutable-content only when it has an image, so other pushes never start your extension.

Add a Notification Service Extension target in Xcode. Add the GrovsNotificationService library to the extension target, not the app. It ships through SPM and CocoaPods only, not in the Grovs.xcframework download.

  • Swift Package Manager: add the GrovsNotificationService product from the same grovs-iOS package.
  • CocoaPods:
Ruby
target 'NotificationService' do
  pod 'GrovsNotificationService', '~> 3.1'
end

Make the extension's principal class inherit from GrovsNotificationServiceExtension:

Swift
import GrovsNotificationService
 
class NotificationService: GrovsNotificationServiceExtension {}

If you already have an extension

An app has one Notification Service Extension. If yours already changes the content (for another provider, say), call GrovsNotificationContent.attachImage(to:session:completion:) from it. It adds the image and keeps your changes and existing attachments. Keep the returned task and cancel it when time runs out. The completion then runs once, without the image:

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

Each push goes to its own handler, even when the system reuses one extension instance for several pushes.

Image formats

  • Formats: JPEG, PNG or GIF. Other formats, including WebP, are skipped and the push arrives without an image.
  • Dashboard uploads: up to 1 MB.
  • image_url: must be https.

Testing

On Apple silicon Macs, the iOS Simulator accepts local test pushes. Save a payload and send it with xcrun simctl push:

JSON
{
  "aps": { "alert": { "title": "Spring sale", "body": "20% off today" } },
  "grovs_campaign_id": "test-campaign",
  "grovs_link": "https://your-domain.sqd.link/spring",
  "promo": "spring26"
}
Bash
xcrun simctl push booted com.your.app payload.json

A tap runs the same path as a real push. The test campaign ID doesn't exist in Grovs, so the open isn't recorded. The SDK logs the failure and retries on the next couple of foregrounds; that's expected.

simctl push doesn't run Notification Service Extensions. Check campaign images on a physical device with a dashboard campaign.

For a real end-to-end test, create a campaign whose audience is your own user ID (the userIdentifier your app sets). Send it to a build on the matching environment.

Asking for permission from an in-app message

An in-app message can carry a button that asks for push permission. The SDK shows the system prompt and registers for remote notifications if allowed. If the person declined before, it opens your app's settings page. Campaigns with this button skip people who already granted permission.

Last updated 2026-10-26
Was this page helpful?