@amrshbib/react-native-notiflow
Self-hosted push notifications for React Native — three lines of setup and
zero Firebase configuration inside your app:
- ❌ no
google-services.json
- ❌ no
GoogleService-Info.plist
- ❌ no google-services gradle plugin
- ❌ no Firebase pods on iOS (pure APNs)
- ❌ no AppDelegate / MainApplication changes
import Notiflow from '@amrshbib/react-native-notiflow';
Notiflow.initialize({
baseUrl: 'https://push.your-server.com',
appId: 'PROJECT_ID',
appKey: 'nk_xxxxxxxx',
});
Notiflow.Notifications.requestPermission();
Notiflow.login('user-42');
Notiflow.User.addTags({ plan: 'pro', city: 'DXB' });
Notiflow.Notifications.addEventListener('click', (e) => {
console.log('clicked', e.notification, e.result.actionId, e.result.url);
});
How it works
- Android — the SDK bundles
firebase-messaging internally and calls
FirebaseApp.initializeApp(context, options) at runtime with client
options fetched from your server (GET /api/mobile/v1/config). Those
options are read straight out of Firebase by your server using the
service-account JSON — no google-services.json anywhere, not in the app
and not in the dashboard.
The backend sends data-only messages to SDK devices and the SDK renders
every notification natively — which is what makes action buttons, images,
foreground control and consistent click handling work.
- iOS — no Firebase at all. The SDK registers directly with APNs, sends
the raw hex device token to your server, and the server delivers over
HTTP/2 APNs with the
.p8 key you uploaded in the dashboard. AppDelegate
callbacks and the UNUserNotificationCenter delegate are hooked by
runtime swizzling (installed before didFinishLaunching, forwarding to any
handler your app installs) — which is also why the iOS bridge is
Objective-C: swizzling needs +load and the ObjC runtime.
Install
npm install @amrshbib/react-native-notiflow
npm install file:../RNPackages/@amrshbib/react-native-notiflow
cd ios && pod install
Full guide with diagrams: Docs/MOBILE-SDK.md
Autolinking does the rest. Requirements: React Native ≥ 0.71 (old + new
architecture via interop), Android minSdk 24, iOS 13+.
One-time dashboard setup (per app)
- FCM service account (sending, Android): App Settings → upload the
service-account JSON.
- Android client config: App Settings → "Detect from service account".
The server reads the client identifiers from Firebase itself.
- APNs key (sending, iOS): App Settings → upload the
.p8 key with Key
ID / Team ID / Bundle ID.
- Create a mobile Secure Key (optionally locked to your bundle ids) —
that's the
appKey.
The only platform config you can't avoid
- iOS: enable the Push Notifications capability (Xcode → Signing &
Capabilities). That's an entitlement Apple requires per app; no SDK can
inject it. Recommended: also enable Background Modes → Remote
notifications.
- Android: nothing.
API
Notiflow.initialize(opts) | Boot the SDK: fetch config, mint the push token, register the device. All other calls queue behind it. |
Notiflow.login(externalId) | Bind this install to your user id (/auth/link). Survives token rotation. |
Notiflow.logout() | Unbind (/auth/unlink). |
Notiflow.getMe() | Server-side view of the logged-in user (all subscriptions + tags). |
Notiflow.User.addTag(k, v) / addTags({...}) | Merge tags (user-level when logged in, else this install). |
Notiflow.User.removeTag(k) / removeTags([k]) | Delete tag keys. |
Notiflow.User.getTags() | Local cache of tags set through this install. |
Notiflow.User.getExternalId() | Current external id (or null). |
Notiflow.User.pushSubscription.getId() | Stable subscription id (treat as a secret). |
Notiflow.User.pushSubscription.getToken() | Raw push token (FCM registration token / APNs hex). |
Notiflow.User.pushSubscription.optOut() / optIn() | Server-side unsubscribe / resubscribe. |
Notiflow.Location.setLocation(lat, long) | Report coordinates for radius targeting. Shown as "Location point" on the user record. |
Notiflow.Location.getLocation() | Last coordinates reported from this install (or null). |
Notiflow.Location.clearLocation() | Forget them — the device stops matching radius segments. |
Notiflow.Notifications.requestPermission() | OS prompt (iOS, Android 13+). Resolves granted. |
Notiflow.Notifications.hasPermission() | Current permission state. |
Notiflow.Notifications.clearAll() | Clear delivered notifications (+ badge on iOS). |
Notiflow.Notifications.setForegroundDisplayEnabled(bool) / getForegroundDisplayEnabled() | Device-local toggle: show pushes while the app is open. Default: true. |
Notiflow.Notifications.setBackgroundDisplayEnabled(bool) / getBackgroundDisplayEnabled() | Device-local toggle: show pushes while the app is backgrounded (Android only). Default: true. |
Notiflow.Channels.sync() | Re-read the app's channels from /config and apply them. Runs on start already. |
Notiflow.Channels.list() | Channels on this phone with their live importance and a blocked flag (Android only). |
Notiflow.Channels.definitions() | What the server last sent, from the local cache. |
Notiflow.Channels.openSettings(key) | Open the OS settings screen for one channel. |
Notiflow.setLogLevel('verbose') | none / error / warn / info / verbose. |
Location & radius targeting
Report where the device is and the dashboard can target it by radius —
"everyone within 5 km of the store". The coordinates land on the user record as
Location point (Audience → Users), and the segment builder gets a
Location → is within radius of filter taking latitude, longitude and a
radius in metres.
The SDK deliberately never reads GPS and never asks for the location
permission: your app already owns that decision (and the App Store / Play
justification for it), so it hands us a fix whenever it has one.
import Geolocation from '@react-native-community/geolocation';
Geolocation.getCurrentPosition(({ coords }) => {
Notiflow.Location.setLocation(coords.latitude, coords.longitude);
});
await Notiflow.Location.getLocation();
await Notiflow.Location.clearLocation();
The value is cached on-device and replayed on every re-registration, so it
survives push-token rotation and reinstalls. Call setLocation again whenever
you have a newer fix — only the latest one is kept, and there is no background
tracking: a device that never reports coordinates simply never matches a radius
segment.
Events
Notiflow.Notifications.addEventListener('click', (e) => {
e.notification;
e.result.actionId;
e.result.url;
});
Notiflow.Notifications.addEventListener('received', (e) => {
console.log('push received', e.notification.title, e.notification.data);
});
Notiflow.Notifications.addEventListener('foregroundWillDisplay', (e) => {
if (e.notification.data.silent === '1') e.preventDefault();
});
Notiflow.Notifications.addEventListener('permissionChange', (granted) => {});
Notiflow.User.pushSubscription.addEventListener('change', ({ id, token, optedIn }) => {});
data.url from a campaign is opened automatically on click via Linking
(deep links included). Disable with initialize({ autoOpenLaunchUrls: false }).
Navigate to a screen on click
The click event is the navigation hook — it fires when the app is in the
foreground, background, and on cold start (the tap that launched the app
is buffered until your listener registers, so register it early):
Notiflow.Notifications.addEventListener('click', (e) => {
const { screen, ...params } = e.notification.data;
if (screen) navigationRef.current?.navigate(screen, params);
});
Send the campaign with data { "screen": "OrderDetails", "orderId": "42" }
and set autoOpenLaunchUrls: false if you route everything yourself.
Per-notification display control (showForeground / showBackground)
Add these keys to the notification's data payload when sending; the SDK
enforces them natively (default: both true):
"showForeground": false | Not displayed while the app is open (foregroundWillDisplay is skipped entirely) — the received listener still fires. |
"showBackground": false | Android: not displayed in background/killed — delivery is silent, received is buffered for the next app session. iOS: not enforceable client-side (Apple displays background alerts; needs a Notification Service Extension — planned). |
both false | A silent data push: nothing shows anywhere (Android), your received listener gets the payload. |
The received event always fires regardless of these flags — that's the
"catch and log it" hook. On iOS it fires in the foreground only (background
alert pushes never wake the app without an NSE).
Device-local display toggles (foreground / background)
showForeground / showBackground above are set per message by the sender.
For a setting the user controls on their own device — "show me banners while
I'm using the app" vs. "while it's closed" — use the display toggles. They're
persisted natively and honored on every incoming push, even in a cold process:
await Notiflow.Notifications.setForegroundDisplayEnabled(false);
await Notiflow.Notifications.setBackgroundDisplayEnabled(false);
await Notiflow.Notifications.getForegroundDisplayEnabled();
The device stays subscribed either way (unlike optOut()): the server keeps
targeting it and the received event keeps firing — only the visible banner is
suppressed. setBackgroundDisplayEnabled is Android-only: on iOS the OS
renders background alerts before the app runs, so it can't be honored
client-side without a Notification Service Extension.
Live Activities (iOS) & Live Updates (Android)
The lock-screen card that tracks something in progress — a delivery, a ride, a
match score. Your backend drives it (POST /api/server/v1/live-activity/…);
the SDK's job is the device half.
How much the SDK can do differs sharply by platform, and it's worth knowing why
before you plan the work.
Android — the SDK does everything
Live updates arrive on the device's normal FCM token and the native service
renders an ongoing notification, replacing it in place on every update. Nothing to
set up.
await Notiflow.LiveActivities.register({ activityId: 'order_12345' });
Notiflow.LiveActivities.addEventListener('update', ({ liveUpdate, displayed, outcome }) => {
console.log(liveUpdate.action, liveUpdate.contentState);
if (!displayed) console.warn('live update not shown:', outcome);
});
await Notiflow.LiveActivities.end('order_12345');
⚠️ POST_NOTIFICATIONS must be granted, or nothing renders. From Android 13
it is a runtime permission, and until the user grants it every live update is
dropped on arrival while your server still reports it as delivered. Call
Notiflow.Notifications.requestPermission() at startup. The update event then
reports outcome: 'notifications-disabled' rather than failing silently.
The ongoing notification is drawn from a small set of conventional keys, because
Android can't know how to render an app-defined struct:
| Title | alert.title → contentState.title → app name |
| Body | alert.body → contentState.body / .message / .status |
| Progress bar | contentState.progress (0-100), or .indeterminate: true |
Everything else is still available in full through the update event. Live
updates post to their own notiflow_live_updates channel (default importance, no
badge) so users can silence them separately from regular notifications — name it
with com.amrshbib.notiflow.live_update_channel_name.
Updates are silent unless the sender includes an alert, and an out-of-order
delivery is dropped natively as well as server-side — FCM makes no ordering
guarantee, so two updates sent moments apart can arrive reversed.
Out-of-order protection compares sequence numbers within one numbering stream
(stream_id: the server session, or direct for a session-less test send). It has
to: a session numbers its updates 1, 2, 3… and restarts at 1 for every new
session, while a direct test send numbers by clock (~1.78e9). Comparing across
those would make one test send black-hole every later real update for that activity
id. A change of stream resets the mark instead of comparing against it.
"The dashboard says delivered but I see nothing"
Every way an update can fail to reach the screen is reported as outcome on the
update event (and logged under the Notiflow tag):
shown / ended | It reached the tray |
notifications-disabled | POST_NOTIFICATIONS not granted, or the user turned notifications off — by far the most common cause |
stale-sequence | Older than the last update shown for this activity in the same stream |
opted-out | The device opted out of push entirely |
notify-blocked | The OS refused the notify() call |
render-error | Building the notification threw |
adb logcat -s Notiflow:V
iOS — you own the activity, the SDK owns the server contract
The SDK cannot start or render a Live Activity, and no library can.
ActivityAttributes and its SwiftUI ActivityConfiguration are types that must
be declared in your Widget Extension, and Activity<Attributes> is generic over
them. There is no API by which a package could do this for you.
What the SDK does own is the server contract. Your Swift observes the tokens
ActivityKit hands you and reports them; registration, retry and the HTTP call are
handled:
First make the bridge visible to Swift. With the default React Native setup
(static libraries), add to your app's Objective-C bridging header:
#import "NotiflowLiveActivities.h"
With use_frameworks!, import react_native_notiflow in the Swift file instead.
import ActivityKit
let activity = try Activity<DeliveryAttributes>.request(
attributes: DeliveryAttributes(orderId: "12345"),
content: .init(state: .init(status: "Preparing", eta: 25), staleDate: nil),
pushType: .token)
Task {
for await tokenData in activity.pushTokenUpdates {
NotiflowLiveActivities.reportActivityToken(
NotiflowLiveActivities.hexString(from: tokenData),
forActivity: "order_12345",
attributesType: "DeliveryAttributes")
}
}
Task {
for await tokenData in Activity<DeliveryAttributes>.pushToStartTokenUpdates {
NotiflowLiveActivities.reportPushToStartToken(
NotiflowLiveActivities.hexString(from: tokenData),
attributesType: "DeliveryAttributes")
}
}
Reports are queued until JS is ready, so a token observed during app launch is
never lost. Add NSSupportsLiveActivities: true to your Info.plist.
You don't need to call register() on iOS — reported tokens register themselves.
It's there if you'd rather pass a token in from JS.
There is no update event on iOS: ActivityKit updates the widget directly and
your app process is never woken.
Reconciling after a cold start
iOS can restore activities your process knows nothing about. Ask the server what
it still thinks is live, then re-report tokens or end what's stale:
const sessions = await Notiflow.LiveActivities.getSessions();
Android customization (optional)
AndroidManifest.xml meta-data, all optional:
<meta-data android:name="com.amrshbib.notiflow.default_notification_icon"
android:resource="@drawable/ic_stat_notify" />
<meta-data android:name="com.amrshbib.notiflow.default_notification_color"
android:value="#2E7DF6" />
<meta-data android:name="com.amrshbib.notiflow.default_channel_name"
android:value="Notifications" />
Notifications post to a high-importance notiflow_default channel unless the
campaign names one — see below.
Notification channels
Channels are defined server-side (dashboard → App Settings → Channels, the
third section beside Project and Team) and created on the phone by the SDK. One
channel holds the Android side (importance, sound, vibration, lights,
lock-screen visibility, badge, DND bypass, grouping) and the iOS side (sound,
interruption level, relevance score, thread id, critical alerts). An app
normally runs several at once.
initialize() fetches them from /config and creates them before the first push
can land. A channel created later still works on its first send: the definition
travels inside the push and the SDK creates the channel on the spot.
await Notiflow.Channels.sync();
const onDevice = await Notiflow.Channels.list();
const muted = onDevice.filter((c) => c.blocked);
await Notiflow.Channels.openSettings('orders');
Importance has four rungs, and sound, vibration and lights are each off,
default or custom:
importance | low | Silent, no banner (IMPORTANCE_LOW) |
| medium | Makes a sound (IMPORTANCE_DEFAULT) |
| high | Sound + heads-up banner (IMPORTANCE_HIGH) |
| urgent | IMPORTANCE_MAX — the same threshold as high |
sound | { mode: 'off' } | No sound |
| { mode: 'default' } | The phone's default notification tone |
| { mode: 'custom', name: 'order_ding' } | res/raw/order_ding |
vibration | { mode: 'off' } | No vibration |
| { mode: 'default' } | Vibrates, OS pattern |
| { mode: 'custom', pattern: [0, 250, 250, 250] } | That pattern, in ms |
led | { mode: 'off' } | No light |
| { mode: 'default' } | Blinks, OS colour |
| { mode: 'custom', color: '#2E7DF7' } | Blinks in that colour |
urgent and high are indistinguishable on the device — Android clamps
IMPORTANCE_MAX down — so Channels.list() reports 4 for both.
Sounds ship inside the app — sound mode custom names a file, it never
downloads one:
| Android | android/app/src/main/res/raw/order_ding.mp3 | order_ding — no extension, [a-z0-9_] |
| iOS | the app bundle, order_ding.caf | order_ding.caf — with extension, ≤ 30 s |
A name that is not bundled falls back to the default tone and logs a warning, so
a missing file never means a silent push.
Android channels are immutable after creation, so editing the sound or importance
server-side creates nf_<key>_v<n+1> and deletes nf_<key>_v<n> on the next
sync — a user who muted the old one starts hearing the new one. If your app
already creates its own channels, set App-owned channel id on the channel and
the SDK sends that id through without creating anything — but note that a pinned
id never changes, so a channel that carries one keeps whatever sound, importance
and vibration the phone created it with, no matter what the dashboard says.
Known limitations (v1)
- iOS action buttons & rich images need a Notification Service Extension
(an Apple constraint, not ours). Not included yet; Android
supports both natively.
getTags() returns the local cache; use getMe() for the server truth.
- Badges on Android are channel-driven (
setBadgeCount is iOS-only).
Channels.list() and openSettings(key) are Android-only; on iOS they return
[] and open the app's own settings page, because iOS has no channels.
- iOS
time-sensitive and critical need an Apple entitlement — without it APNs
accepts the push and iOS ignores the level.
- iOS Live Activities need app-side Swift — the
ActivityAttributes and the
widget must live in your Widget Extension, so no library can supply them. The
SDK provides the token bridge; see the section above. Android needs nothing.
- Android live updates can't be scheduled for removal. An
END with a future
dismissal_date leaves a dismissible card rather than auto-clearing later —
Android has no equivalent of ActivityKit's dismissal date.
Troubleshooting
notiflow/no-android-config — the server has no Android push identity
for this app yet: open App Settings and run "Detect from service account".
notiflow/token-timeout on iOS — simulators can't get APNs tokens;
use a real device and check the Push Notifications capability.
- No pushes on iOS — upload the APNs
.p8 key in App Settings; make sure
sandbox matches your build type (debug = sandbox).
- Android background pushes delayed — aggressive OEM battery savers
(Xiaomi/Huawei…) throttle high-priority data messages for apps the user
force-stopped; this affects every push SDK equally.