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.

Demo env missing: add VITE_DEMO_VAPID_KEY to packages/pwa/.env, then restart Vite.

Integration

Pick your platform. Sign up to replace pb_YOUR_KEY_HERE below with your own sdkKey — everything else stays the same.

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.Activity
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
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)
        registerActivityOpenTracking(context.applicationContext)

        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 registerActivityOpenTracking(appContext: Context) {
        val app = appContext as? Application ?: return
        app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
            override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
                trackOpenFromIntent(activity.intent)
            }
            override fun onActivityNewIntent(activity: Activity, intent: Intent) {
                trackOpenFromIntent(intent)
            }
            override fun onActivityStarted(activity: Activity) {}
            override fun onActivityResumed(activity: Activity) {}
            override fun onActivityPaused(activity: Activity) {}
            override fun onActivityStopped(activity: Activity) {}
            override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
            override fun onActivityDestroyed(activity: Activity) {}
        })
    }

    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
            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

Built into `Pushbrain.kt` — `init()` registers activity lifecycle hooks so tray taps fire the open beacon automatically. Custom launcher flows can still call `Pushbrain.trackOpenFromIntent(intent)` from `MainActivity.onCreate` / `onNewIntent`.

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

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    Pushbrain.trackOpenFromIntent(intent)
}
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, then send a notification from the form below. Android does not auto-display FCM notifications while the app is in the foreground.

AISet up with Claude Code / Cursor instead

Hand this prompt to an AI coding agent already working in your codebase — it detects your platform and wires up the SDK itself, using this app's real sdkKey and API base.

Agent prompt
You are integrating push notifications using Pushbrain (a bring-your-own-Firebase push notification service) into this codebase.

Context:
- Pushbrain sdkKey: pb_YOUR_KEY_HERE (public, safe to embed in client code — this is not a secret)
- Pushbrain API base: /api
- Device registration endpoint: POST /api/sdk/register-token

Task:
1. Detect this project's platform (web/React, Android/Kotlin, Flutter, or React Native) from its existing dependencies and file structure.
2. Install the matching Pushbrain SDK:
   - Web: npm install @pushbrain/sdk firebase
   - React Native: npm install pushbrain-react-native @react-native-firebase/app @react-native-firebase/messaging
   - Android/Flutter: no native SDK package yet — call the registration endpoint above directly (POST platform, fcmToken, sdkKey) after obtaining an FCM token via the Firebase SDK already in the project.
3. Call pushbrain.init({ sdkKey: 'pb_YOUR_KEY_HERE', ... }) (web/React Native) from the app's startup code, using the project's OWN existing Firebase config — do not invent new Firebase credentials.
4. Do NOT hardcode a different sdkKey than the one above. Do NOT create a new Firebase project for this — if the project has no Firebase project connected yet, stop and ask the user to connect one first rather than guessing.
5. After wiring it up, ask the user to confirm the Pushbrain dashboard shows a new device registered — that confirmation step needs a human with dashboard access, not the agent.

Guardrails:
- The sdkKey above is meant to be public/client-embedded — do not confuse it with an API key (prefixed pb_sk_), which is a server-side secret and must never appear in client code.
- If the project has no notification-permission flow yet, add one before calling init() — init() requests permission itself, but the app should handle a denied result gracefully, not crash.

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 (optional)

Scheduled jobs can gate a send on current weather — e.g. only notify users in cities where it's currently raining. This is fully opt-in, per app, and off by default. Pushbrain never derives location itself: it only stores the city (and optional coordinates) your app explicitly passes at registration, and only after you enable "Location collection" for that app in the Integration tab. If it's disabled, any location fields the SDK sends are silently dropped server-side.

  • Enable location collection for the app in the Integration tab first — without it, weather conditions can't be added to a scheduled job.
  • If your codebase already has the user's city or coordinates, pass city and/or lat/lng directly when initializing the SDK.
  • If it doesn't, set requestLocation: true instead — the web SDK will ask the browser's own location permission (a separate prompt from notifications) and use that. Only turn this on if you don't already have the user's location some other way.
  • Devices with no location on file are simply skipped by weather-gated jobs — they still receive every other notification normally.
pushbrain.init({
  sdkKey: 'pb_YOUR_KEY_HERE',
  // ...your existing config

  // Option A — you already have the user's location:
  city: 'Bangalore',   // optional — only stored if enabled in the dashboard
  lat: 12.9716,        // optional, alongside or instead of city
  lng: 77.5946,

  // Option B — ask the user directly on your site instead:
  // requestLocation: true,
});
Ready to ship?
Sign up, paste your Firebase service-account JSON, get your sdkKey. ~2 minutes.
Sign up →