# Push notifications

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

Source: https://docs.grovs.io/docs/sdk/ios/push-notifications

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](https://docs.grovs.io/docs/engagement/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](https://developer.apple.com/account/resources/authkeys/list), 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](https://app.grovs.io), 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.

> **Warning:** **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

| Situation             | What happens                                                                                                                                                       |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Before `configure`    | The tap is held and handled once `configure` runs. With `enabled: false`, it's dropped.                                                                            |
| Before authentication | The open is sent once the SDK authenticates, even if a later attempt is the one that succeeds.                                                                     |
| Network failure       | Retried at the next foreground, up to 3 attempts. Grovs counts one open per device, so retries never double-count.                                                 |
| App restart           | Unreported opens (failed, in flight or awaiting authentication) are saved per API key and environment, then sent after the next launch authenticates.              |
| No consent            | While 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. |

> **Warning:** 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](https://docs.grovs.io/docs/sdk/ios/in-app-messages) 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.
