---
title: Kindly Android SDK — Push Notifications Reference
slug: sdk/android-sdk-skill-push
description: Complete reference for Firebase Cloud Messaging push notifications in the Kindly Android SDK. Use when registering FCM tokens, routing incoming RemoteMessages to Kindly, or coexisting with another notification provider (Braze, Airship, OneSignal, etc.).
createdAt: 2026-11-04T11:39:57.207Z
---

Deep reference for wiring Firebase Cloud Messaging (FCM) push notifications to Kindly on Android. Companion to [SKILL.md](https://docs.kindly.ai/sdk/android-sdk-skill.md).

Push notifications power "agent has replied" alerts when the chat is in the background. The SDK needs two things from you, both delivered through your `FirebaseMessagingService`:

1. The **FCM token** when Firebase issues one (`onNewToken`)
2. **Incoming `RemoteMessage`s** that originate from Kindly (`onMessageReceived`)

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

Both calls work before `start()`. A push that arrives after the process was killed is decrypted with the key stored at registration and shown, and tapping it opens the chat once `start()` runs (see [Process death and cold start](docId\:O0EefvayeqRQ7_XA8U5z3)).

***

## Prerequisites

- Firebase added to your project (`google-services.json` in `app/`, Google Services Gradle plugin applied).
- `firebase-messaging` dependency in `app/build.gradle`:

  ```kotlin
  implementation("com.google.firebase:firebase-messaging-ktx:24.0.0")
  ```
- A Firebase Server Key uploaded in the Kindly admin panel (under your bot's **Mobile** settings).
- `POST_NOTIFICATIONS` runtime permission requested (Android 13+).

***

## 1. Create a `FirebaseMessagingService`

```kotlin
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import no.kindly.chatsdk.chat.KindlySDK

class AppMessagingService : FirebaseMessagingService() {

    override fun onNewToken(token: String) {
        super.onNewToken(token)
        KindlySDK.saveNotificationToken(token)
    }

    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        super.onMessageReceived(remoteMessage)

        if (KindlySDK.isKindlyNotification(remoteMessage)) {
            KindlySDK.handleNotification(remoteMessage)
            return
        }

        // not from Kindly — handle elsewhere
    }
}
```

Register the service in `AndroidManifest.xml`:

```xml
<service
    android:name=".AppMessagingService"
    android:exported="false">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>
```

***

## 2. Coexist with another provider

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

```kotlin
class AppMessagingService : FirebaseMessagingService() {

    override fun onNewToken(token: String) {
        super.onNewToken(token)
        KindlySDK.saveNotificationToken(token)
        OtherProvider.registerToken(token)
    }

    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        super.onMessageReceived(remoteMessage)

        if (KindlySDK.isKindlyNotification(remoteMessage)) {
            KindlySDK.handleNotification(remoteMessage)
            return
        }

        OtherProvider.handleMessage(remoteMessage)
    }
}
```

Only **one** `FirebaseMessagingService` can be registered per app — every provider has to flow through this single class.

***

## 3. Intercept and observe via `KindlySDKInteraction`

For every Kindly push the SDK successfully decrypts while it is started, it calls `shouldHandleNotification(notification)` on `KindlySDKInteraction` **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. Before `start()` the SDK still asks it when your interface is set in this process (for example after `kill()`); in a fresh process there is none and the SDK posts the banner itself.

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

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

"On screen" means the chat activity is started and visible. Once the user backs out of the chat, or another activity covers it, banners are shown again.

The interface 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`:

```kotlin
data class ExternalNotification(
    val id: String,                   // decrypted chat_id
    val title: String,                // decrypted title
    val body: String,                 // decrypted body
    val data: Map<String, String>,    // original RemoteMessage.data
)
```

### Implementation

```kotlin
import no.kindly.chatsdk.chat.common.KindlySDKInteraction
import no.kindly.chatsdk.chat.domain.entites.ExternalNotification

KindlySDK.`interface` = object : KindlySDKInteraction {
    override fun didPressButton(chatButton: ButtonChat, chatLog: List<MessageChat>) { /* ... */ }

    override fun shouldHandleNotification(notification: ExternalNotification): Boolean {
        Analytics.track("kindly_notification", notification.id)

        if (isShowingCustomInbox) {
            customInbox.add(notification)
            return false   // SDK skips the system notification
        }
        return true        // let the SDK post the system notification (subject to the display rule)
    }
}
```

### Polarity

Same as `shouldHandleLink(url)`:

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

Returning `false` only suppresses the system notification — 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.

***

## Process death and cold start

FCM delivers Kindly's data-only messages to `onMessageReceived` even when the app process is not running (unless the user force-stopped the app). In that process nothing has called `KindlySDK.start(...)` yet, and the SDK handles it on its own:

- `isKindlyNotification` is a pure check on the payload
- `handleNotification` decrypts with the key persisted when the chat registered the device, creates the channel if needed and posts the banner. `shouldHandleNotification` is asked when your interface is set in this process (after `kill()`), a fresh process has none
- the banner's tap launches your launcher activity and the chat opens once `start()` has run

You do not need to call `start()` from the messaging service. A device that never registered (the chat was never opened while a token was known) has no key, the push is logged as `no_keys` and dropped.

***

## API Reference

### `KindlySDK.saveNotificationToken(token: String)`

Stores the FCM token in memory and uploads it to Kindly on every connect. Safe to call before or after `start()`; a token given before `start()` is applied when the SDK starts. The token is not persisted, so provide it again after a process restart (the `FirebaseMessaging.getInstance().token` call at app start in the pattern below does this).

### `KindlySDK.handleNotification(remoteMessage: RemoteMessage)`

Tell the SDK that a Kindly notification arrived. The SDK:

- Decrypts the payload with the key the device received when the chat registered for pushes
- Offers the decrypted `ExternalNotification` to `shouldHandleNotification` when the SDK is started
- Skips the banner while the chat conversation is on screen (see the display rule above)
- Creates the `kindly_channel` channel if needed and posts the system notification

Works before `start()` too, see [Process death and cold start](docId\:O0EefvayeqRQ7_XA8U5z3).

### `KindlySDK.isKindlyNotification(remoteMessage: RemoteMessage): Boolean`

Returns `true` if the `RemoteMessage.data` carries Kindly's `notification_source` marker. A pure check on the payload, it works before `start()`. Use this to route between providers.

### `` KindlySDK.`interface` ``

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

### `ExternalNotification`

A `data class` carrying the decrypted notification (`id`, `title`, `body`) plus the original `RemoteMessage.data` map for any extra fields the backend attached. Passed to `shouldHandleNotification(notification)`.

***

## Notification tap opens the chat

Tapping a Kindly banner opens the chat, with nothing to wire on your side:

- SDK started: the chat opens directly
- SDK not started (the app was killed, or the tap launched it): the SDK starts your launcher activity, and the chat opens as soon as your `Application.onCreate()` has called `KindlySDK.start(...)`. A tap older than 60 seconds is dropped

Set `KindlySDK.openChatOnNotificationTap = false` to keep the launch but not open the chat, and read the tap yourself:

```kotlin
import no.kindly.chatsdk.chat.KindlyNotificationIntent

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    KindlyNotificationIntent.read(intent)?.let { tap -> /* tap.id (chat id), tap.title, tap.body, tap.data */ }
}

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    setIntent(intent)
    KindlyNotificationIntent.read(intent)?.let { tap -> /* ... */ }
}
```

`KindlyNotificationIntent.read(intent)` returns the decrypted `ExternalNotification` behind the tap, or `null` for any other intent; `isKindlyNotificationTap(intent)` is the boolean form. The extras are hints for your UI, not authenticated data: treat them like any other extra an exported launcher activity can receive.

***

## End-to-End Pattern

```kotlin
// AppMessagingService.kt
class AppMessagingService : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        super.onNewToken(token)
        KindlySDK.saveNotificationToken(token)
    }

    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        super.onMessageReceived(remoteMessage)
        if (KindlySDK.isKindlyNotification(remoteMessage)) {
            KindlySDK.handleNotification(remoteMessage)
        }
    }
}

// App.kt
class App : Application() {
    override fun onCreate() {
        super.onCreate()
        KindlySDK.start(application = this, botKey = "YOUR_BOT_KEY", market = "YOUR_MARKET")

        // Bootstrap the FCM token at app start (covers app-update and reinstall cases)
        FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
            KindlySDK.saveNotificationToken(token)
        }
    }
}

// MainActivity.kt — optional: see which notification opened the app (the SDK opens the chat by itself)
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        KindlyNotificationIntent.read(intent)?.let { Analytics.track("kindly_notification_opened", it.id) }
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)
        KindlyNotificationIntent.read(intent)?.let { Analytics.track("kindly_notification_opened", it.id) }
    }
}
```

```xml
<!-- AndroidManifest.xml -->
<service android:name=".AppMessagingService" android:exported="false">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>

<!-- singleTask: a tap while the app is already running arrives in onNewIntent -->
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTask">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>
```

***

## Debugging

| Symptom                                                     | Likely cause                                                                                                                                                                                                                                                                                                                             |
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No notifications arriving                                   | (a) Server Key not configured in Kindly admin. (b) Application ID mismatch with `google-services.json`. (c) The token was never provided in this process (it is kept in memory only, so provide it again at app start). (d) The chat was never opened with a token on this device, so it has no decryption key (logcat shows `no_keys`). |
| `ClassNotFoundException: FirebaseMessagingService`          | Add `firebase-messaging` to your `app/build.gradle` dependencies.                                                                                                                                                                                                                                                                        |
| Tap on notification doesn't open chat                       | `KindlySDK.openChatOnNotificationTap` is `false`, or the app was killed and `KindlySDK.start(...)` did not run within 60 seconds of the tap (call it in `Application.onCreate()`).                                                                                                                                                       |
| Push arrived while the app was killed but nothing was shown | Check logcat for `permission_denied` / `notifications_disabled` (notifications are off for the app) or `decrypt_failed` (the key on the device does not match the chat, for example after app data was restored from another device).                                                                                                    |
| Notifications missing on Android 13+                        | The user denied `POST_NOTIFICATIONS`; request it via `ActivityResultContracts.RequestPermission()`.                                                                                                                                                                                                                                      |

Enable verbose logging to see push handshake messages:

```kotlin
KindlySDK.setVerboseLogging(true)
```

***

## Notes & Gotchas

- Only one `FirebaseMessagingService` can be registered per app. If you already have one, **don't** add a second — extend the existing one to call into Kindly.
- `onNewToken` fires on token rotation, not on every app launch. To catch the case where the user reinstalled the app between launches and token rotation hasn't happened yet, fetch the token at app start with `FirebaseMessaging.getInstance().token`.
- The SDK posts the system notification itself when `handleNotification` is called — you don't need to call `NotificationManagerCompat.notify(...)` yourself for Kindly messages.
- The SDK's display gate suppresses the system notification **only** when the app is in the foreground and the chat conversation screen is actually on screen. Sub-screens of the SDK (settings, language, image preview, etc.) and host-app screens still receive notifications; 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.
- The system notification is posted on the single `kindly_channel` channel (`IMPORTANCE_HIGH`, default sound), shown to the user as "Chat messages" in the system settings, translated into the device's language where the SDK ships one (23 languages, English otherwise). SDK versions before 2.0.15 created one channel per notification; the first push handled by this version removes those.
- A tap always opens the chat at the latest message; there is no per-message routing.
- Banners are posted with `VISIBILITY_PUBLIC`, so the decrypted text also shows on a locked screen. If your content must stay hidden there, return `false` from `shouldHandleNotification` and post your own notification.
