Push notifications,
dropped in.

Pushbrain is a multi-tenant push notification service. You bring your own Firebase project, drop a few lines into your app, and broadcast notifications from a dashboard or HTTP API. Encrypted credentials, your data, your FCM tenant — we just route the send.

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.

Integration

For the full Firebase integration guide — SDK snippets, service-account setup, and campaign workflows — see Firebase integration →.

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.

Adding to an existing app

Already shipped to users? Drop Pushbrain into a normal release — no data migration, no parallel backend, no user disruption.

  • Your existing Firebase project keeps working. Paste its service-account JSON into Pushbrain — we encrypt and store it.
  • Add the ~30 lines from your platform's snippet above to your app's startup code.
  • Ship a normal release. Existing users register on next app launch — no token migration needed; Pushbrain stores them as they arrive.
  • If you already have a way to send pushes, keep it running in parallel until everyone updates. The two paths don't conflict — they both go through your same FCM project.

Location & weather-aware sends

Try weather for free with Integration → Test weather notification(test devices only). Production weather-gated Automations that filter by each user's live weather require Pro.

  • Free: preview AI + weather copy and send to marked test devices.
  • Pro: enable location collection, send city/lat/lng from the SDK, add weather conditions on Automations.
  • Devices without location on file are skipped by weather-gated jobs — they still get other notifications.

Read the full weather guide →

Ready to ship?
Sign up, paste your Firebase service-account JSON, get your sdkKey. ~2 minutes.
Sign up →