Firebase FCM dashboard & push notification integration

Pushbrain is a Firebase push strategy layer for indie developers and small SaaS teams — campaigns, audience health, and AI-assisted messaging on top of your existing FCM project, without vendor lock-in.

Pushbrain is a full Firebase push notification dashboard on top of FCM: connect your existing project once, then run campaigns, scheduling, AI copy, and audience health without building a notification backend from scratch.

Try it live — no signup required

See a real push notification arrive in this browser before connecting any Firebase project of your own. The token this creates is used once to send you one message, then discarded — nothing is stored.

Why add a layer on FCM?

Firebase Cloud Messaging is free, reliable transport. It is not a campaign tool — no scheduling UI, no token health dashboard, no AI copy, no A/B tests. Pushbrain fills that gap while your Firebase project remains the sender of record.

Pushbrain vs raw FCM →

SDK integration

Pick your platform. Sign up to replace pb_YOUR_KEY_HERE with your own sdkKey.

Prerequisites

  • A Firebase project for your app, with an Android app registered for your package name.
  • `google-services.json` downloaded from Firebase Console → Project Settings → Your apps → Android, dropped into `app/`.
  • This Pushbrain app created (you are looking at the dashboard for it) so you have the `sdkKey` below.
  • Real phone testing: if the API base below is localhost, replace it with your Mac LAN IP such as http://192.168.1.20:3030, or use an HTTPS tunnel. A physical phone cannot reach your Mac at localhost.
11. Add Gradle dependencies

Project-level `build.gradle.kts`:

build.gradle.kts (project)
plugins {
    id("com.google.gms.google-services") version "4.4.2" apply false
}
app/build.gradle.kts
plugins {
    id("com.android.application")
    id("kotlin-android")
    id("com.google.gms.google-services")
}

dependencies {
    implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
    implementation("com.google.firebase:firebase-messaging-ktx")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
}
22. Manifest permissions
app/src/main/AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>

<!-- Debug-only if your API_BASE is plain http:// on Android 9+. Prefer HTTPS in production. -->
<application android:usesCleartextTraffic="true" ... />
33. Request Android 13+ notification permission

Call this from your first Activity before testing pushes.

MainActivity.kt
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build

private fun requestNotificationPermission() {
    if (
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
        checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
    ) {
        requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1001)
    }
}
44. Drop in Pushbrain.kt

Create this file anywhere in your `app/src/main/java/...` package.

Pushbrain.kt
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import com.google.firebase.messaging.FirebaseMessaging
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject

object Pushbrain {
    private const val SDK_KEY = "pb_YOUR_KEY_HERE"
    private const val API_BASE = "/api"

    private val http = OkHttpClient()

    fun init(context: Context) {
        createNotificationChannel(context)
        val installId = getOrCreateInstallId(context)

        FirebaseMessaging.getInstance().token
            .addOnSuccessListener { token -> register(token, installId) }
            .addOnFailureListener { e -> Log.w("Pushbrain", "getToken failed", e) }
    }

    /** Fires the open-rate beacon when a notification tap delivers trackUrl in intent extras. */
    fun trackOpenFromIntent(intent: Intent?) {
        if (intent == null) return
        val trackUrl = intent.getStringExtra("trackUrl")
            ?: intent.getStringExtra("notifId")?.let { "$API_BASE/t/o?n=$it" }
        if (trackUrl != null) fireOpenBeacon(trackUrl)
    }

    private fun fireOpenBeacon(trackUrl: String) {
        http.newCall(Request.Builder().url(trackUrl).build()).enqueue(object : okhttp3.Callback {
            override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {}
            override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) = response.close()
        })
    }

    /** Stable per-install id, persisted locally. Lets Pushbrain recognize this
     *  device again after its FCM token rotates instead of double-counting it. */
    private fun getOrCreateInstallId(context: Context): String {
        val prefs = context.getSharedPreferences("pushbrain", Context.MODE_PRIVATE)
        return prefs.getString("install_id", null) ?: java.util.UUID.randomUUID().toString().also {
            prefs.edit().putString("install_id", it).apply()
        }
    }

    private fun createNotificationChannel(context: Context) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return

        val channel = NotificationChannel(
            "default",
            "Default",
            NotificationManager.IMPORTANCE_HIGH
        ).apply {
            description = "Pushbrain push notifications"
        }

        context.getSystemService(NotificationManager::class.java)
            .createNotificationChannel(channel)
    }

    private fun register(fcmToken: String, installId: String) {
        val body = JSONObject().apply {
            put("sdkKey", SDK_KEY)
            put("fcmToken", fcmToken)
            put("platform", "android")
            put("installId", installId)
            put("appVersion", BuildConfig.VERSION_NAME)
            put("environment", if (BuildConfig.DEBUG) "local" else "production")
            put("deviceLabel", "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
            put("userEmail", currentUserEmailOrNull) // Optional: app user's email, if signed in
            put("userName", currentUserNameOrNull)   // Optional: app user's display name
            // Optional location — enable Location collection in the dashboard first:
            // put("city", currentUserCityOrNull)
            // put("lat", currentUserLatOrNull)
            // put("lng", currentUserLngOrNull)
            put("permissionStatus", "granted")
            put("timezone", java.util.TimeZone.getDefault().id)
            put("sdkVersion", "android-snippet-1")
        }.toString().toRequestBody("application/json".toMediaType())

        http.newCall(
            Request.Builder()
                .url("$API_BASE/sdk/register-token")
                .post(body)
                .build()
        ).enqueue(object : okhttp3.Callback {
            override fun onFailure(call: okhttp3.Call, e: java.io.IOException) =
                Log.w("Pushbrain", "register failed", e)
            override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
                Log.d("Pushbrain", "registered: ${response.code}")
                response.close()
            }
        })
    }
}
55. Call init from your Application

In your `Application` subclass (register it in `AndroidManifest.xml` as `android:name=".MyApp"`):

MyApp.kt
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        Pushbrain.init(this)
    }
}
66. Open-rate tracking

Open counts only increase when a user taps the notification. Forward launch intents from `MainActivity` so Android tray taps fire the open beacon. Opening the app icon manually does not count as a notification open.

MainActivity.kt
import android.content.Intent
import android.os.Bundle

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    Pushbrain.trackOpenFromIntent(intent)
}

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    setIntent(intent)
    Pushbrain.trackOpenFromIntent(intent)
}
7Location & weather (optional)

To power weather-gated Automations (Pro), enable Location collection in Integration and send city and/or lat/lng on registration. Preview weather for free with Integration → Test weather notification. Full guide: /docs/weather-notifications

Verify it works

Build and run on a phone, accept the notification permission prompt, and confirm the backend logs POST /sdk/register-token. Then refresh this dashboard — the device count ticks up within ~5 seconds. Background or lock the phone, send a notification, then tap the notification itself. Opening the app icon manually will not increase opens.

Reach existing users without an app update

If you already store FCM tokens, your installed base can receive Pushbrain sends before they update. Connect the same Firebase project, import tokens via the HTTP API, then ship the SDK later for tracking and token refresh.

  • Paste your existing Firebase service-account JSON into Pushbrain — the same project, same tokens.
  • From your backend, POST each stored token to /sdk/register-token with sdkKey, fcmToken, and platform. Prefer recently active tokens (Android tokens older than ~270 days are often expired).
  • Send from the dashboard immediately — no new opt-in prompt and no App Store / Play update required for those devices.
  • Ship the SDK in a later release for open/click tracking and automatic token refresh. Parallel sends through your same FCM project are fine during rollout.
curl
curl -X POST /api/sdk/register-token \
  -H 'Content-Type: application/json' \
  -d '{"sdkKey":"pb_YOUR_KEY_HERE","fcmToken":"EXISTING_FCM_TOKEN","platform":"android"}'
Ready to connect Firebase?
Sign up, paste service-account JSON, get your sdkKey. ~2 minutes.
Sign up →