PusherHub
For developers

One call, however many devices.

Drop in the push notification SDK for Flutter, Kotlin, Java, Swift, React Native or Unity, or send push notifications from your own backend with the REST API. Batching, retries and dead-token cleanup are handled for you.

Overview

PusherHub stores your devices, works out who a notification should go to, and delivers it through your own Firebase project — you connect a Firebase service account, and messages go out under your project, not a shared one. Your device tokens stay yours.

1. Register

Your app calls register-device with its FCM token. PusherHub stores the device, its platform, language, timezone and approximate country.

2. Send

You send from the dashboard or POST /send-notification. PusherHub fans the audience out in batches of 500 through FCM.

3. Report back

Opens and clicks are tracked, dead tokens are retired, and signed webhooks tell your backend what happened.

A send to a large audience goes out in batches and resumes where it left off if a worker restarts. Devices that Firebase rejects as unregistered are marked uninstalled rather than retried forever.

Your two API keys

Every app has a public key and a secret key. They are not interchangeable, and the difference matters more than it first looks.

pk_live_… Public key

Goes inside your mobile app. It can register a device, update or delete that device and report its own opens and clicks.

Safe to ship: anyone can pull it out of an APK, so it is deliberately limited to one device at a time.

sk_live_… Secret key

Stays on your own server. It can send notifications and list every device and delivery record for the app.

Never ship this in an app, a web page, or anything a user can read.

Both keys are on your Developers page once you sign in. The secret key can't be changed from the dashboard, so keep it on your server and treat it like a password.

Authentication

Every request is a POST with a JSON body, and carries its key as a bearer token.

Base URL
https://pusherhub.com/api
Headers
Authorization: Bearer YOUR_KEY
Content-Type: application/json
Accept: application/json

Secret-key endpoints also require app_key in the body, naming which of your apps the call is for. Public-key endpoints accept it too, and reject the request if it does not match the key.

Rate limits

Public-key endpoints: 60 requests per minute. Secret-key endpoints: 30 requests per minute. Both are counted per caller. Going over returns 429.

SDK quickstart

The SDK handles the FCM token, registration, token rotation, notification permission, heartbeats and open/click tracking. You need a Firebase project set up in your app first (google-services.json on Android, GoogleService-Info.plist on iOS), because PusherHub delivers through it.

Add firebase_core, firebase_messaging and shared_preferences to your pubspec.yaml, drop pusher_hub.dart into lib/, then:

main.dart
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'pusher_hub.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  // Lets notifications arrive while the app is terminated.
  FirebaseMessaging.onBackgroundMessage(pusherHubBackgroundMessageHandler);

  final pusherHub = PusherHub(
    appKey: 'YOUR_APP',
    publicKey: 'pk_live_your_public_key',
    baseUrl: 'https://pusherhub.com/api',
  );

  await pusherHub.registerDevice();

  runApp(MyApp());
}

That is the whole required setup. registerDevice() fetches the FCM token, registers the device, and keeps it up to date when the token rotates. Until someone signs in with login() it uses a persistent anonymous ID, reused across restarts.

Optional extras

Signing users in and out
// When someone signs in, attach this device to them — remembered
// across app restarts until logout().
await pusherHub.login('user-123');

// On sign-out, forget them; the device stays registered, anonymously.
await pusherHub.logout();
Richer targeting
// Device model, brand, language and timezone, for segments.
await pusherHub.collectDeviceMetadata();

// Pass resolveLocation: true to also resolve state and city from GPS.
// Asks for location permission — only do this if your app already needs it.
await pusherHub.collectDeviceMetadata(resolveLocation: true);
Opening a screen from Custom Data
final navigatorKey = GlobalKey<NavigatorState>(); // MaterialApp(navigatorKey: navigatorKey)

final pusherHub = PusherHub(
  appKey: 'YOUR_APP',
  publicKey: 'pk_live_your_public_key',
  baseUrl: 'https://pusherhub.com/api',
  onNotificationOpened: (data) {
    // Every tap — app open, in the background or closed — with the
    // notification's Custom Data, e.g. {"campaign_id": "232"}.
    final campaignId = data['campaign_id'];
    if (campaignId != null) {
      navigatorKey.currentState?.pushNamed('/campaign', arguments: campaignId);
    }
  },
);
Handling a tapped notification yourself
final pusherHub = PusherHub(
  appKey: 'YOUR_APP',
  publicKey: 'pk_live_your_public_key',
  baseUrl: 'https://pusherhub.com/api',
  onNotificationClick: (deepLink) {
    // By default the SDK opens the link in an external browser.
    Navigator.of(context).pushNamed(deepLink);
  },
);

Add Firebase Messaging to your module's build.gradle and put google-services.json in place, then drop PusherHub.kt into your source tree.

Application.onCreate()
import com.pusherhub.sdk.PusherHub

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()

        PusherHub.initialize(
            context = this,
            appKey = "YOUR_APP",
            publicKey = "pk_live_your_public_key",
            baseUrl = "https://pusherhub.com/api",
        )

        PusherHub.registerDevice()
    }
}

registerDevice() finishes asynchronously — it needs the FCM token first — so if you want to run something straight after it, pass the callback rather than calling it on the next line.

Optional extras

Sequencing after registration
PusherHub.registerDevice { PusherHub.collectDeviceMetadata() }
Signing users in and out
PusherHub.login("user-123")   // attach this device to a user, remembered until logout()
PusherHub.logout()            // forget them; stays registered, anonymously
Notification permission (Android 13+)
// In the Activity that asked for POST_NOTIFICATIONS:
override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,
    grantResults: IntArray,
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    PusherHub.onRequestPermissionsResult(requestCode)
}
Token rotation, if you have your own FirebaseMessagingService
override fun onNewToken(token: String) {
    super.onNewToken(token)
    PusherHub.handleNewToken(token)
}
Opening a screen from Custom Data
PusherHub.initialize(
    context = this,
    appKey = "YOUR_APP",
    publicKey = "pk_live_your_public_key",
    baseUrl = "https://pusherhub.com/api",
    onNotificationOpened = { data ->
        // Every tap — app open, in the background or closed — with the
        // notification's Custom Data, e.g. {"campaign_id": "232"}.
        data["campaign_id"]?.let { campaignId ->
            startActivity(Intent(this, CampaignActivity::class.java).putExtra("campaign_id", campaignId))
        }
    },
)
Handling a tapped notification yourself
PusherHub.initialize(
    context = this,
    appKey = "YOUR_APP",
    publicKey = "pk_live_your_public_key",
    baseUrl = "https://pusherhub.com/api",
    onNotificationClick = { deepLink ->
        // Default behaviour opens the link in a browser.
        startActivity(Intent(this, DetailActivity::class.java).putExtra("link", deepLink))
    },
)

The same SDK as Kotlin, as a Java source file. Add Firebase Messaging and google-services.json to your project, then drop PusherHub.java into your source tree.

Application.onCreate()
import com.pusherhub.sdk.PusherHub;

public class MyApp extends Application {
    @Override
    public void onCreate() {
        super.onCreate();

        PusherHub.initialize(
            this,
            "YOUR_APP",
            "pk_live_your_public_key",
            "https://pusherhub.com/api"
        );

        PusherHub.registerDevice();
    }
}

registerDevice() finishes asynchronously, so use the Runnable overload if something has to happen after it rather than calling it on the next line.

Optional extras

Sequencing after registration
PusherHub.registerDevice(() -> PusherHub.collectDeviceMetadata());

// Or with your own user ID:
PusherHub.registerDevice("user-123", () -> PusherHub.collectDeviceMetadata());
Signing users in and out
PusherHub.login("user-123");  // attach this device to a user, remembered until logout()
PusherHub.logout();           // forget them; stays registered, anonymously
Notification permission (Android 13+)
@Override
public void onRequestPermissionsResult(
    int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    PusherHub.onRequestPermissionsResult(requestCode);
}
Token rotation, if you have your own FirebaseMessagingService
@Override
public void onNewToken(@NonNull String token) {
    super.onNewToken(token);
    PusherHub.handleNewToken(token);
}
Opening a screen from Custom Data
PusherHub.initialize(
        this,
        "YOUR_APP",
        "pk_live_your_public_key",
        "https://pusherhub.com/api",
        null, // your deep-link listener, if you have one
        data -> {
            // Every tap — app open, in the background or closed — with the
            // notification's Custom Data, e.g. {"campaign_id": "232"}.
            String campaignId = data.get("campaign_id");
            if (campaignId != null) {
                startActivity(new Intent(this, CampaignActivity.class).putExtra("campaign_id", campaignId));
            }
        });

Needs iOS 15 or later. In Xcode, add FirebaseCore and FirebaseMessaging from firebase-ios-sdk (File › Add Package Dependencies), put GoogleService-Info.plist in your app target, and turn on the Push Notifications capability. Then drop PusherHub.swift into your app target.

iOS pushes go through Firebase too, so Firebase needs your Apple push key: create an APNs key (.p8) under Certificates, Identifiers & Profiles › Keys, and upload it in the Firebase console under Project settings › Cloud Messaging. Without it the device registers but no push reaches it. Receiving pushes needs a paid Apple Developer account.

AppDelegate.swift
import UIKit

final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        PusherHub.initialize(
            appKey: "YOUR_APP",
            publicKey: "pk_live_your_public_key",
            baseUrl: "https://pusherhub.com/api"
        )

        // Asks for notification permission, gets the FCM token and registers the device.
        PusherHub.registerDevice()
        return true
    }

    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        PusherHub.didRegisterForRemoteNotifications(deviceToken: deviceToken)
    }

    func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
        PusherHub.didFailToRegisterForRemoteNotifications(error: error)
    }
}
YourApp.swift (SwiftUI)
import SwiftUI

@main
struct YourApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
                .pusherHubMessageHost()   // optional: shows in-app messages
        }
    }
}

Initialize in the app delegate, not in a view: iOS only passes a tap that launched the app to a delegate that was there before launch finished. A UIKit app adds the same lines to the AppDelegate it already has.

Optional extras

Sequencing after registration
PusherHub.registerDevice {
    PusherHub.collectDeviceMetadata()   // model, app version, language, country, timezone
}
Signing users in and out
PusherHub.login(externalId: "user-123")   // attach this device to a user, remembered until logout()
PusherHub.logout()                          // forget them; stays registered, anonymously
Opening a screen from Custom Data
PusherHub.initialize(
    appKey: "YOUR_APP",
    publicKey: "pk_live_your_public_key",
    baseUrl: "https://pusherhub.com/api",
    onNotificationOpened: { data in
        // Every tap — app open, in the background or closed — with the
        // notification's Custom Data, e.g. {"campaign_id": "232"}.
        NotificationRouter.shared.campaignId = data["campaign_id"]
    }
)
Handling a tapped notification yourself
PusherHub.initialize(
    appKey: "YOUR_APP",
    publicKey: "pk_live_your_public_key",
    baseUrl: "https://pusherhub.com/api",
    onNotificationClick: { deepLink in
        // Default behaviour opens the link with UIApplication.open.
        router.open(deepLink)
    }
)

Images in notifications: iOS doesn't download a notification's picture by itself. Add a Notification Service Extension target to your app and use the NotificationService.swift from the Swift steps in your dashboard — it attaches the image, with no extra keys or App Group.

Install @react-native-firebase/app, @react-native-firebase/messaging, @notifee/react-native and @react-native-async-storage/async-storage, then drop PusherHub.js into your project.

App.js
import PusherHub, { PusherHubMessageHost } from './PusherHub';

export default function App() {
  useEffect(() => {
    (async () => {
      await PusherHub.initialize({
        appKey: 'YOUR_APP',
        publicKey: 'pk_live_your_public_key',
        baseUrl: 'https://pusherhub.com/api',
      });

      PusherHub.attachNotificationHandlers();
      await PusherHub.registerDevice();
    })();
  }, []);

  return (
    <PusherHubMessageHost>
      <YourApp />
    </PusherHubMessageHost>
  );
}

attachNotificationHandlers() wires up foreground messages, taps and the token-refresh listener. PusherHubMessageHost is only needed if you want in-app messages rendered for you.

Optional extras

Signing users in and out
await PusherHub.login('user-123');  // attach this device to a user, remembered until logout()
await PusherHub.logout();           // forget them; stays registered, anonymously
Richer targeting
await PusherHub.collectDeviceMetadata();
Opening a screen from Custom Data
await PusherHub.initialize({
  appKey: 'YOUR_APP',
  publicKey: 'pk_live_your_public_key',
  baseUrl: 'https://pusherhub.com/api',
  onNotificationOpened: (data) => {
    // Every tap — app open, in the background or closed — with the
    // notification's Custom Data, e.g. {"campaign_id": "232"}.
    // A tap that opened a closed app can come before navigation is ready:
    // keep it and navigate in <NavigationContainer onReady> (see the guide).
    if (data.campaign_id && navigationRef.isReady()) navigationRef.navigate('Campaign', { id: data.campaign_id });
  },
});
Handling a tapped notification yourself
await PusherHub.initialize({
  appKey: 'YOUR_APP',
  publicKey: 'pk_live_your_public_key',
  baseUrl: 'https://pusherhub.com/api',
  onNotificationClick: (deepLink) => navigation.navigate(deepLink),
});

iOS

This SDK has so far been exercised on Android. On iOS you also need the APNs capabilities and entitlements set up in Xcode, and to register for remote notifications — see @react-native-firebase's iOS setup guide.

Import FirebaseMessaging.unitypackage from the Firebase Unity SDK — the same version as any Firebase packages already in your project — and add google-services.json / GoogleService-Info.plist anywhere under Assets/. Then drop PusherHub.cs into Assets/PusherHub/ and add:

Assets/Scripts/PusherHubSetup.cs
using PusherHubSDK;
using UnityEngine;

public static class PusherHubSetup
{
    // Runs once when the game starts — no GameObject or scene change needed.
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
    static void Init()
    {
        PusherHub.Initialize(
            appKey: "YOUR_APP",
            publicKey: "pk_live_your_public_key",
            baseUrl: "https://pusherhub.com/api");

        PusherHub.RegisterDevice();
    }
}

That is the whole required setup, for Android and iOS alike. There is no Android manifest or Xcode project to edit: while Unity builds, PusherHub.cs adds the notification-tap intent filter on Android, and on iOS the Push Notifications capability, the remote-notification background mode and a Notification Service Extension for images. A tapped notification opens its deep link with Application.OpenURL.

Optional extras

Signing players in and out
PusherHub.Login(currentUserId);  // attach this device to a player, remembered until Logout()
PusherHub.Logout();               // forget them; stays registered, anonymously
Richer targeting
PusherHub.RegisterDevice(onComplete: () => PusherHub.CollectDeviceMetadata());
In-app messages
// Shows them now and every time the game returns to the foreground…
PusherHub.EnableInAppMessages();

// …or only at calm moments, e.g. when the main menu opens:
PusherHub.CheckForInAppMessages();
Opening a screen from Custom Data
PusherHub.Initialize(
    appKey: "YOUR_APP",
    publicKey: "pk_live_your_public_key",
    baseUrl: "https://pusherhub.com/api",
    onNotificationOpened: data =>
    {
        // Every tap — game open, in the background or closed — with the
        // notification's Custom Data, e.g. {"campaign_id": "232"}.
        if (data.TryGetValue("campaign_id", out var campaignId)) CampaignScreen.Open(campaignId);
    });
Handling a tapped notification yourself
PusherHub.Initialize(
    appKey: "YOUR_APP",
    publicKey: "pk_live_your_public_key",
    baseUrl: "https://pusherhub.com/api",
    onNotificationClick: link => OpenScreen(link));

In-app messages are drawn with Unity UI on top of the whole game: banner, modal, fullscreen, center popup and bottom sheet, with simple or blocks content. HTML messages are skipped — Unity has no built-in web view.

Before the first build

Android: run Assets › External Dependency Manager › Android Resolver › Force Resolve. iOS: install CocoaPods on the Mac that builds, and upload your Apple team's APNs key (.p8) to the app's Firebase project — without it iPhones register but never receive a push.

Where to get the SDK file

Sign in and open Developers; each SDK has a download button there. The file is a single source file you drop into your project — there is no package to add and nothing to keep in sync with a registry.

REST API

Every endpoint is POST, relative to https://pusherhub.com/api.

Sending

Secret key. From your server only.

POST /send-notification Secret key

Sends to a whole audience. Returns as soon as the send is queued — delivery happens in the background, in batches of 500.

Field Type Required Description
app_key string required Which app to send from.
title string required Notification title. Max 255 characters.
message string required Body text.
audience_type string required One of: all, android, ios, user, segment.
audience_external_id string if user The external_id you registered the device with.
audience_segment_id integer if segment A segment belonging to this app.
image string optional Big-picture image URL.
large_icon string optional Used as the image when image is empty.
sound string optional File name of a sound added inside your app, e.g. coin.wav — plays on iPhones (Android uses the app's own sound). Leave out for the normal sound.
badge integer optional The number on your app's icon on iPhones; 0 clears it. Android phones show their own dot.
click_action string optional Not used: the SDKs ignore it. To open a page or screen on tap, use deep_link or custom_data.
deep_link string optional Opened when the notification is tapped.
custom_data object optional Any extra key/values, delivered in the data payload. These names are taken, by PusherHub or Firebase, and refused with a 422: notification_id, click_action, deep_link, from, notification, message_type, collapse_key, and any name starting with google or gcm.
Request
curl -X POST https://pusherhub.com/api/send-notification \
  -H "Authorization: Bearer YOUR_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "app_key": "YOUR_APP",
    "title": "Your delivery has arrived",
    "message": "Left at the front door.",
    "audience_type": "all"
  }'
201 Created
{
  "success": true,
  "notification_id": 42,
  "status": "queued"
}
POST /send-test Secret key

Sends one message straight to a single FCM token, skipping the queue and the audience rules. Takes token, title and message. Returns 422 if the app has no Firebase credentials.

Devices

Public key, except where noted. These are what the SDKs call for you.

POST /register-device Public key

Registers a device, or updates it if the token is already known. Call it on every launch — it is idempotent.

Field Type Required Description
external_id string required Your own ID for the user. The SDKs generate a persistent anonymous one if you do not pass it.
token string required The FCM registration token.
platform string required android or ios.
previous_token string optional The token this install last registered. Lets PusherHub follow a rotated token instead of counting an install plus an uninstall.
device string optional Model name.
brand string optional Manufacturer.
version string optional Your app version.
language string optional Device language.
timezone string optional IANA timezone.
country / state / city string optional Filled in from the request IP when omitted.
notification_permission string optional granted, denied or not_determined.
201 Created
{
  "success": true,
  "device_id": 1174
}
POST /update-device Public key

Updates a device found by token. Pass new_token to move it to a rotated token, or external_id to attach it to a user who has just signed in. All other fields match register-device.

POST /heartbeat Public key

Marks the device active now. Takes token. Drives the realtime view and the inactive-device cleanup.

POST /delete-device Public key

Removes the device with this token.

POST /list-devices Secret key

Lists this app's devices with their tokens and external IDs. Takes app_key, page and per_page (max 200). Secret key only — it hands out every token in the app.

Tracking

Public key. Both take notification_id and token.

POST /track-open Public key

Records that the notification was opened. The first open also fires the notification.opened webhook. The notification_id arrives in the notification's own data payload.

POST /track-click Public key

Records a click on the notification.

POST /list-tracking Secret key

Delivery records for the app — sent, delivered, opened and clicked timestamps. Takes app_key, optionally notification_id, plus page and per_page.

In-app messages

Public key. Modals and banners shown inside your app, without an app release.

POST /fetch-in-app-messages Public key

Returns the messages this device is currently eligible for. Takes token.

POST /track-in-app-shown Public key

Records that a message was displayed. Takes token and in_app_message_id. /track-in-app-click and /track-in-app-dismiss take the same two.

Webhooks

Add a webhook URL on the Developers page and PusherHub will POST to it when things happen. Every delivery is signed, so your endpoint can prove it came from your PusherHub and not from someone who guessed the URL.

Events

device.registered A new device registered for the app.
device.deleted A device was removed.
notification.sent A notification finished fanning out.
notification.delivered FCM accepted the message for a device.
notification.opened A device reported the first open.
notification.failed Delivery failed for a device.

What you receive

POST to your URL
X-PusherHub-Signature: 9f86d081884c7d659a2feaa0c55ad015a…

{
  "event": "notification.opened",
  "app_key": "YOUR_APP",
  "data": {
    "notification_id": 42,
    "device_id": 1174
  },
  "sent_at": "2026-10-29T11:53:51+00:00"
}

Verifying the signature

X-PusherHub-Signature is an HMAC-SHA256 of the raw request body, keyed with your app's webhook secret. Compare it against the raw body — not a re-encoded version of the parsed JSON, which will not match.

PHP
$body = file_get_contents('php://input');
$expected = hash_hmac('sha256', $body, $yourWebhookSecret);

if (! hash_equals($expected, $_SERVER['HTTP_X_PUSHERHUB_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}

$payload = json_decode($body, true);
Node.js (Express)
app.post('/webhooks/pusherhub',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const expected = crypto
      .createHmac('sha256', process.env.PUSHERHUB_WEBHOOK_SECRET)
      .update(req.body)
      .digest('hex');

    const given = req.get('X-PusherHub-Signature') || '';

    if (expected.length !== given.length ||
        !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given))) {
      return res.sendStatus(401);
    }

    const payload = JSON.parse(req.body);
    res.sendStatus(200);
  });

Good to know

  • • Your URL must be a public internet address. Private, loopback and cloud-metadata addresses are refused, both when you save the URL and again at delivery time.
  • • Redirects are not followed — point the webhook at its final URL.
  • • Requests time out after 10 seconds. Answer quickly and do your own work afterwards.
  • • Every attempt and its response is listed on your Developers page.

Errors & limits

Status What it means
401 Missing or invalid API key, or the key does not match the app_key you sent.
403 The application is not active.
404 The device or notification in the request does not belong to this app.
422 A field failed validation — the response body names it. Also returned by send-test when the app has no Firebase credentials.
429 Rate limit exceeded, or the plan's notification allowance for this period is used up. The body says which.

Validation errors follow Laravel's usual shape, so the field name is always in the response:

422 Unprocessable Content
{
  "message": "The title field is required.",
  "errors": {
    "title": ["The title field is required."]
  }
}

Ready to send your first notification?

Create an account, connect your Firebase project, and your keys are waiting on the Developers page. The Free plan needs no card.

Get started