---
name: kindly-ios-sdk-push
description: Complete reference for APNS push notifications in the Kindly iOS SDK. Use when registering APNS device tokens, routing incoming notifications to Kindly, or coexisting with another notification provider (Braze, Airship, OneSignal, etc.).
category: sdk-reference
parent: kindly-ios-sdk
---

# Kindly iOS SDK — Push Notifications Reference

Deep reference for wiring APNS push notifications to Kindly on iOS. Companion to [SKILL.md](https://kindly-ai.github.io/sdk-chat-ios-sources/SKILL.md).

Push notifications power "agent has replied" alerts when the chat is in the background. The SDK needs two things from you:

1. The **APNS device token** when iOS issues one
2. **Routing of incoming notifications** that originate from Kindly

How you wire #2 depends on whether Kindly is the only push provider in your app.

---

## Prerequisites

- Push Notifications capability enabled in Xcode (**Signing & Capabilities** → **+** → **Push Notifications**).
- Background Modes → **Remote notifications** enabled.
- An APNS auth key uploaded in the Kindly admin panel (under your bot's **Mobile** settings).
- The user has granted notification permission via `UNUserNotificationCenter.requestAuthorization`.

---

## 1. Register the APNS device token

Forward the token to Kindly from your `AppDelegate`:

```swift
func application(
    _ application: UIApplication,
    didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
    KindlySDK.setAPNSDeviceToken(deviceToken)
}
```

Call this **after** `KindlySDK.start(...)`. The SDK persists the token and uploads it on the next chat session.

```swift
public class func setAPNSDeviceToken(_ token: Data)
```

The SDK accepts the raw `Data` — no need to convert to a hex string yourself.

---

## 2. Route notifications to Kindly

### Option A: Let the SDK handle everything (Kindly is the only provider)

Assign `KindlySDK.notificationDelegate` to the system notification center. The SDK provides a fully-implemented `UNUserNotificationCenterDelegate`:

```swift
import UserNotifications

func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
    UNUserNotificationCenter.current().delegate = KindlySDK.notificationDelegate

    UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in
        DispatchQueue.main.async { application.registerForRemoteNotifications() }
    }
    return true
}
```

This is the simplest setup and handles both **foreground presentation** and **tap-to-open**.

### Option B: Coexist with another provider

If you already have a notification delegate (Braze, Airship, OneSignal, your own), keep it and forward to Kindly only when the notification is from Kindly:

```swift
import UserNotifications

extension AppDelegate: UNUserNotificationCenterDelegate {

    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
    ) {
        if KindlySDK.isKindlyNotification(notification.request.content.userInfo) {
            // The SDK decides: nothing on the chat screen, the decrypted content otherwise
            KindlySDK.notificationWillPresent(notification, completionHandler: completionHandler)
            return
        }
        // Hand off to your other provider
        OtherProvider.handleForeground(notification, completionHandler: completionHandler)
    }

    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse,
        withCompletionHandler completionHandler: @escaping () -> Void
    ) {
        let userInfo = response.notification.request.content.userInfo
        if KindlySDK.isKindlyNotification(userInfo) {
            KindlySDK.notificationResponseReceived(response)
            completionHandler()
            return
        }
        OtherProvider.handleResponse(response, completionHandler: completionHandler)
    }
}
```

---

## 3. Intercept and observe via the delegate

For every Kindly push your app process sees in plaintext (a silent push the SDK decrypted, or a visible push decrypted by the SDK or already by the Notification Service Extension) it calls `shouldHandleNotification(notification:)` on the delegate **before** any display gate is applied. Use it to run analytics, log to a custom inbox, or take over presentation entirely with your own UI.

### Display rule (after the delegate returns `true`)

| App state | Active screen | SDK posts a banner? |
| --- | --- | --- |
| Background | any | ✅ Yes |
| Foreground | Chat conversation | ❌ No (the user can already see the message) |
| Foreground | Settings, language, image preview, host-app screens, etc. | ✅ Yes |

With the Notification Service Extension (Section 4) a visible push is decrypted before iOS shows it, so killed and background states are covered by iOS itself; the rule above then decides only what happens in the foreground.

The delegate callback fires regardless of this rule — it runs for every successfully decrypted Kindly push, so it is the right hook for analytics that must run even while the chat is open.

### Payload

The callback receives a fully-decrypted `ExternalNotification`:

```swift
public struct ExternalNotification {
    public let id: String                         // decrypted chat_id
    public let title: String                      // decrypted title
    public let body: String                       // decrypted body
    public let userInfo: [AnyHashable: Any]       // original APNs userInfo (aps + extras)
}
```

### Implementation

```swift
extension AppDelegate: KindlyChatClientDelegate {
    func shouldHandleNotification(notification: ExternalNotification) -> Bool {
        Analytics.track("kindly_notification", id: notification.id)

        if isShowingCustomInbox {
            customInbox.append(notification)
            return false   // SDK skips the banner
        }
        return true        // let the SDK present the banner (subject to the display rule)
    }
}

KindlySDK.delegate = self
```

### Polarity

Same as `shouldHandleLink(url:)`:

- `true` (default) → SDK handles. Subject to the display rule.
- `false` → host handles. SDK skips the local banner.

Returning `false` only suppresses the local banner — the underlying message has already been delivered to the chat session via the websocket / `/latest` endpoint, so it will appear in chat history when the user opens it.

---

## 4. Notification Service Extension (recommended, SDK 3.0.10+)

With an NSE in your app, Kindly sends a **visible push** (`aps.alert` + `mutable-content: 1`) instead of a silent one. iOS runs your extension before showing the banner, in every app state including when the app was killed, and the extension decrypts the Kindly payload so the banner shows the real title and text. Silent pushes are rate limited by iOS (roughly 3 per hour per app) and are never delivered to a force-quit app; visible pushes have neither problem.

The switch is per bot and manual: once your app ships the extension, ask your Kindly contact to enable alert notifications for your bot. Kindly only sends the visible push to devices running SDK 3.0.10 or newer; older versions keep receiving silent pushes, and nothing changes for you until the bot is switched.

### What the SDK does

| Push | App state | App has the extension | User sees |
| --- | --- | --- | --- |
| silent | any | any | exactly what it sees today |
| visible | killed or background | yes | one banner with the decrypted text, produced by the extension |
| visible | killed or background | no | the placeholder "New message / You have a new message." |
| visible | foreground, on the chat conversation | any | nothing |
| visible | foreground, elsewhere | yes | one banner with the decrypted text |
| visible | foreground, elsewhere | no | one decrypted banner posted by the SDK, the placeholder is hidden |

`shouldHandleNotification(notification:)` fires once per push whenever your app process sees the plaintext, which is every row above except killed/background with the extension (your app is not running there). Ciphertext is never shown: if the extension cannot decrypt (no key yet, device not unlocked since a reboot) iOS shows the placeholder.

### Step 1: Add the extension target

File → New → Target → **Notification Service Extension**, name it (for example `KindlyNotificationService`) and embed it in your app. Replace the generated `NotificationService.swift` with:

```swift
import Kindly

final class NotificationService: KindlyNotificationServiceExtension {}
```

Link `Kindly` into the extension target (General → Frameworks and Libraries; with SPM add the `Kindly` product to the target). Xcode prints a linker warning that the framework is not safe for use in application extensions; it is expected, the base class only uses extension-safe API.

Override `customize(_:payload:)` to add a category, thread identifier, sound or attachments. `payload` is `nil` when decryption failed and the placeholder is still in place.

If you already have an extension for another provider, keep it and call the SDK for Kindly pushes:

```swift
if KindlyPushDecryption.isKindlyNotification(request.content.userInfo),
   let payload = KindlyPushDecryption.decrypt(userInfo: request.content.userInfo) {
    KindlyPushDecryption.apply(payload, to: bestAttemptContent)
}
```

`apply` writes the decrypted title and body and marks the content as decrypted (`kindly_decrypted`, `kindly_chat_id`, `kindly_title`, `kindly_body` in `userInfo`, the ciphertext stays). Keep that call even when you then set your own title, body or attachments: without the markers the app treats the push as still encrypted when it arrives in the foreground and posts its own banner next to yours. `DecryptedPayload` gives you `chatId`, `title` and `body` for anything custom.

### Step 2: Share the keychain between the app and the extension

The SDK stores the decryption key in the app's keychain during token registration; the extension reads it from there. Use the app's own bundle id as the group, as shown: the SDK's other keychain items (auth tokens included) live in the app's default group, and listing a suite-wide group shared with other apps first would move them all into it. On **both** targets, Signing & Capabilities → `+ Capability` → **Keychain Sharing**, and add the same group. Use your app's bundle identifier as the group so keys written by earlier versions stay readable:

```xml
<key>keychain-access-groups</key>
<array>
    <string>$(AppIdentifierPrefix)ai.kindly.Example</string>
</array>
```

`ai.kindly.Example` is the Example app's bundle id, use yours. Regenerate provisioning profiles for both targets if you sign manually.

### Step 3: Ask Kindly to enable alert notifications

Tell your Kindly contact the app version that ships the extension. Kindly switches the bot to alert notifications; devices on SDK 3.0.10 or newer then receive the visible push, everything older keeps the silent one.

### Foreground presentation with your own delegate

If you use your own `UNUserNotificationCenterDelegate` (Option B), hand the presentation decision to the SDK instead of hard-coding options, so the chat-screen rule and the placeholder replacement apply:

```swift
func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    willPresent notification: UNNotification,
    withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
    if KindlySDK.isKindlyNotification(notification.request.content.userInfo) {
        KindlySDK.notificationWillPresent(notification, completionHandler: completionHandler)
        return
    }
    OtherProvider.handleForeground(notification, completionHandler: completionHandler)
}
```

### Tapping a banner

Tapping a Kindly banner opens the chat (`KindlySDK.openChatOnNotificationTap`, default `true`). A tap that launched the app is remembered and the chat opens once you have called `start()`. Set the flag to `false` to keep your own navigation. The remembered tap is replayed only when `start()` runs within 60 seconds of it; after that it is dropped, with a log line.

### Testing

`xcrun simctl push` bypasses APNs and never runs an extension, so test with a real push. On an Apple Silicon Mac the simulator registers a real APNs token; send to the sandbox environment with your APNs key. On a device use a development build. `log stream --predicate 'process == "KindlyNotificationService"'` shows the extension run.

---

## API Reference

### `KindlySDK.setAPNSDeviceToken(_:)`

Stores the APNS token and uploads it to Kindly. Safe to call before or after `start()` — if called before, the token is queued.

### `KindlySDK.notificationDelegate`

A ready-made `UNUserNotificationCenterDelegate` that handles every Kindly-originated notification (foreground presentation + tap-to-open). Assign to `UNUserNotificationCenter.current().delegate` for the all-in-one setup.

### `KindlySDK.isKindlyNotification(_ userInfo: [AnyHashable: Any]) -> Bool`

Returns `true` if the notification payload was sent by Kindly. Use this to route between providers. Works before `start()`, so it can route a tap that launched the app.

### `KindlySDK.notificationReceived(_ userInfo: [AnyHashable: Any])`

Hand the SDK a notification from `didReceiveRemoteNotification` (silent pushes). The SDK decrypts it, calls `shouldHandleNotification`, and posts a local banner unless the user is on the chat conversation. For a visible push delivered in the foreground use `notificationWillPresent` instead.

### `KindlySDK.notificationWillPresent(_ notification: UNNotification, completionHandler:)`

Call from your own `willPresent` and pass its completion handler through. Returns banner and sound for notifications that are not Kindly's; for Kindly notifications it applies the display rule and replaces a placeholder with the decrypted banner when there is no extension.

### `KindlySDK.notificationResponseReceived(_ response: UNNotificationResponse)`

Tell the SDK the user **tapped** a Kindly notification. Opens the chat when `openChatOnNotificationTap` is on; a tap that launched the app opens the chat once `start()` has run.

### `KindlySDK.openChatOnNotificationTap`

`Bool`, default `true`. Whether a tapped Kindly banner opens the chat.

### `KindlyNotificationServiceExtension`

`open class`, subclass it in your Notification Service Extension target. Decrypts Kindly pushes before iOS shows them and marks the content so the SDK never shows it twice. Override `customize(_:payload:)` to change the content.

### `KindlyPushDecryption`

For extensions you already own: `isKindlyNotification(_:)`, `decrypt(userInfo:) -> DecryptedPayload?`, `apply(_:to:)` and `isDecrypted(_:) -> Bool` (whether a payload was already rewritten), which writes the plaintext into a `UNMutableNotificationContent` and marks it as decrypted.

### `KindlySDK.delegate`

Type: `KindlyChatClientDelegate?`. Set to receive `shouldHandleNotification(notification:)` (and the existing `shouldHandleLink(url:)` and `didPressButton(...)`) callbacks. The protocol's `shouldHandleNotification` has a default implementation that returns `true`, so you only need to implement it if you want to observe or intercept notifications.

### `ExternalNotification`

A `public struct` carrying the decrypted notification (`id`, `title`, `body`) plus the original APNs `userInfo` for any extra fields the backend attached. Passed to `shouldHandleNotification(notification:)`.

---

## End-to-End Pattern (Option B with another provider)

```swift
import UIKit
import UserNotifications
import Kindly

@main
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        KindlySDK.start(botKey: "YOUR_BOT_KEY", market: "YOUR_MARKET")

        UNUserNotificationCenter.current().delegate = self
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
            guard granted else { return }
            DispatchQueue.main.async { application.registerForRemoteNotifications() }
        }
        return true
    }

    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
        KindlySDK.setAPNSDeviceToken(token)
        OtherProvider.registerToken(token)
    }

    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                willPresent notification: UNNotification,
                                withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        let userInfo = notification.request.content.userInfo
        if KindlySDK.isKindlyNotification(userInfo) {
            KindlySDK.notificationReceived(userInfo)
            completionHandler([.banner, .sound])
            return
        }
        OtherProvider.handleForeground(notification, completionHandler: completionHandler)
    }

    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                didReceive response: UNNotificationResponse,
                                withCompletionHandler completionHandler: @escaping () -> Void) {
        let userInfo = response.notification.request.content.userInfo
        if KindlySDK.isKindlyNotification(userInfo) {
            KindlySDK.notificationResponseReceived(response)
            completionHandler()
            return
        }
        OtherProvider.handleResponse(response, completionHandler: completionHandler)
    }
}
```

---

## Debugging

| Symptom | Likely cause |
|---|---|
| No notifications arriving | (a) APNS auth key not configured in Kindly admin. (b) Bundle ID mismatch. (c) `setAPNSDeviceToken` called but `start()` was never called for the same `botKey`. (d) **Silent-push rate limit** — iOS caps silent pushes at ~3/hour and never delivers them to a force-quit app. Add the Notification Service Extension (Section 4) and ask Kindly to enable alert notifications for your bot. |
| Tap on notification doesn't open chat | You implemented Option B but forgot to call `notificationResponseReceived` for Kindly payloads. |
| Notifications open the system browser | The push payload is being routed as a regular URL — make sure `isKindlyNotification` returns `true` for the payload (check the JSON shape against a Kindly-emitted notification). |
| Banner says "New message / You have a new message." | A visible push arrived and nothing decrypted it: the extension is missing, not linked against `Kindly`, or the two targets do not share the keychain group. Once after a reboot, before the first unlock, this is expected. |
| Two banners for one message | You forward the notification to `notificationReceived` and your own delegate also returns `[.banner]`. Call `notificationWillPresent` from `willPresent` instead. |
| Token registered but Kindly logs "no token" | The token must be set after `start()` for the same `botKey`. Re-call `setAPNSDeviceToken` if you `kill()` and `start()` again. |

Enable verbose logging to see push handshake messages:

```swift
KindlySDK.verboseLogging = true
```

---

## Notes & Gotchas

- The `notificationDelegate` provided by the SDK presents notifications that are not Kindly's with `[.banner, .sound]`. For Kindly notifications it applies the display rule: nothing on the chat conversation, the decrypted content everywhere else. `notificationWillPresent` gives your own delegate the same rule.
- `setAPNSDeviceToken` accepts the raw `Data` from APNS — do not pre-convert it to a hex string.
- iOS rotates device tokens; always re-call `setAPNSDeviceToken` from `didRegisterForRemoteNotificationsWithDeviceToken` (called whenever iOS issues a new token).
- Notification payload routing happens entirely client-side — the SDK does not contact Kindly servers to determine `isKindlyNotification`.
- The SDK's display gate suppresses banners **only** when the app is in the foreground and the user is on the chat conversation screen specifically. Sub-screens of the SDK (settings, language, image preview, etc.) and host-app screens still receive banners; backgrounded apps always receive them.
- Use `shouldHandleNotification(notification:)` for analytics or custom presentation that must run on every Kindly push — it fires for every successfully decrypted notification, regardless of foreground/background state or active screen.
- Local banners scheduled by the SDK play the device's default notification sound (`UNNotificationSound.default`).
- **iOS silent-push rate limit (~3/hour) and no delivery to a force-quit app** are hard constraints of silent pushes. The Notification Service Extension (Section 4) removes both; add it and ask Kindly to enable alert notifications for your bot.
- `xcrun simctl push` never runs a Notification Service Extension. Test the extension with a real push (the simulator on Apple Silicon has a real APNs token) or on a device.
