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.
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.
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.
https://pusherhub.com/api
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:
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
// 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();
// 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);
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);
}
},
);
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.
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
PusherHub.registerDevice { PusherHub.collectDeviceMetadata() }
PusherHub.login("user-123") // attach this device to a user, remembered until logout()
PusherHub.logout() // forget them; stays registered, anonymously
// 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)
}
override fun onNewToken(token: String) {
super.onNewToken(token)
PusherHub.handleNewToken(token)
}
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))
}
},
)
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.
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
PusherHub.registerDevice(() -> PusherHub.collectDeviceMetadata());
// Or with your own user ID:
PusherHub.registerDevice("user-123", () -> PusherHub.collectDeviceMetadata());
PusherHub.login("user-123"); // attach this device to a user, remembered until logout()
PusherHub.logout(); // forget them; stays registered, anonymously
@Override
public void onRequestPermissionsResult(
int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
PusherHub.onRequestPermissionsResult(requestCode);
}
@Override
public void onNewToken(@NonNull String token) {
super.onNewToken(token);
PusherHub.handleNewToken(token);
}
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.
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)
}
}
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
PusherHub.registerDevice {
PusherHub.collectDeviceMetadata() // model, app version, language, country, timezone
}
PusherHub.login(externalId: "user-123") // attach this device to a user, remembered until logout()
PusherHub.logout() // forget them; stays registered, anonymously
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"]
}
)
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.
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
await PusherHub.login('user-123'); // attach this device to a user, remembered until logout()
await PusherHub.logout(); // forget them; stays registered, anonymously
await PusherHub.collectDeviceMetadata();
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 });
},
});
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:
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
PusherHub.Login(currentUserId); // attach this device to a player, remembered until Logout()
PusherHub.Logout(); // forget them; stays registered, anonymously
PusherHub.RegisterDevice(onComplete: () => PusherHub.CollectDeviceMetadata());
// 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();
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);
});
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.
/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. |
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"
}'
{
"success": true,
"notification_id": 42,
"status": "queued"
}
/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.
/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. |
{
"success": true,
"device_id": 1174
}
/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.
/heartbeat
Public key
Marks the device active now. Takes token.
Drives the realtime view and the inactive-device cleanup.
/delete-device
Public key
Removes the device with this token.
/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.
/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.
/track-click
Public key
Records a click on the notification.
/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.
/fetch-in-app-messages
Public key
Returns the messages this device is currently eligible for. Takes
token.
/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
X-PusherHub-Signature: 9f86d081884c7d659a2feaa0c55ad015a…
{
"event": "notification.opened",
"app_key": "YOUR_APP",
"data": {
"notification_id": 42,
"device_id": 1174
},
"sent_at": "2026-10-29T12:44:34+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.
$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);
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:
{
"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