Kindly iOS SDK — Push Notifications Reference
Deep reference for wiring APNS push notifications to Kindly on iOS. Companion to SKILL.md.
Push notifications power "agent has replied" alerts when the chat is in the background. The SDK needs two things from you:
- The APNS device token when iOS issues one
- 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:
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.
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:
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:
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:
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
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 = selfPolarity
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.12+)
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.
There is no switch to ask for. When your app registers for push the SDK tells Kindly whether it can decrypt a visible push, and it works that out by itself: it looks for a Notification Service Extension in your app that is built on the Kindly SDK. Ship the extension and your users get visible pushes from that app version on, while older app versions keep receiving silent pushes. Apps on SDK 3.0.11 or older never declare anything and keep the silent push. See KindlySDK.notificationMode below to override the detection.
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:
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:
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:
<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: Nothing to switch on, but check what the SDK detected
KindlySDK.notificationMode defaults to .automatic, which declares alert when your app bundles a notification service extension whose executable links the Kindly framework (a KindlyNotificationServiceExtension subclass, or your own extension calling KindlyPushDecryption), and silent otherwise. With verbose logging on, the SDK logs the result when it registers for push: Registering for push with notification_mode=alert.
Override it only when detection cannot see what you have:
KindlySDK.notificationMode = .alert // you decrypt Kindly pushes in an extension the SDK cannot detect
KindlySDK.notificationMode = .silent // keep silent pushes even though you ship an extensionSet it before start(). A change applies from the next token registration, which happens every time the chat connects. Kindly can also force silent pushes for a bot from its side, as a kill switch; it cannot force visible pushes onto an app that did not declare them.
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:
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)
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:
KindlySDK.verboseLogging = trueNotes & 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.