PushPal docs
PushPal delivers push (Android, iOS, web), in-app messages, an inbox, email and SMS from one API call — through your own Firebase, APNs, SMTP and SMS accounts. Create an app in the dashboard, connect a push provider, add an SDK, send.
- Sign in, create an app. You get a client key (
pk_…, safe in apps) and can generate a server key (sk_…, backend only). - Settings → Push providers: upload your Firebase service-account JSON (and optionally an APNs key, web push keys).
- Add the SDK (Flutter below), run the app, mark your phone as a test device, press “Send test”.
Firebase & APNs
# Connect the app to your Firebase project (once)
flutter pub add firebase_core firebase_messaging url_launcher
dart pub global activate flutterfire_cli
flutterfire configureFirebase console → Project settings → Service accounts → Generate new private key → upload in PushPal. The key is checked against FCM on upload and sealed at rest (AES-256-GCM, rotatable keyring). It only needs the “Firebase Cloud Messaging API Admin” role.
iOS through FCM needs your APNs key in Firebase. For direct APNs (no FCM on iOS), upload the same .p8 in PushPal with key id, team id and bundle id — validated against Apple on upload. Use Sandbox for Xcode debug builds.
Flutter
Pure Dart (only http + shared_preferences). It attaches to your firebase_messaging, so it never fights your plugin versions.
dependencies:
pushpal_flutter:
git:
url: https://github.com/Neptechpal-pvt-ltd/pushpal_flutter.git
ref: v1.0.0import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:pushpal_flutter/pushpal_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
import 'firebase_options.dart';
final navigatorKey = GlobalKey<NavigatorState>();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
PushPal.configure(appKey: 'pk_your_app_key');
await PushPal.attachFirebase(
FirebaseMessaging.instance,
onMessageOpenedApp: FirebaseMessaging.onMessageOpenedApp,
onMessage: FirebaseMessaging.onMessage,
onUrl: (url) => launchUrl(Uri.parse(url)),
);
PushPal.enableInAppMessages(navigatorKey: navigatorKey);
PushPal.onNotificationOpened.listen((n) {
if (n.targetRoute != null) navigatorKey.currentState?.pushNamed(n.targetRoute!);
});
runApp(MaterialApp(navigatorKey: navigatorKey, home: const HomePage()));
}// attachFirebase asks for permission by default. To ask later instead:
await PushPal.attachFirebase(FirebaseMessaging.instance, requestPermission: false, /* … */);
// …then, at a good moment:
await FirebaseMessaging.instance.requestPermission();
// In debug builds, print a setup checklist:
if (kDebugMode) await PushPal.runSetupTest();Foreground notifications on Android
// Android doesn't show pushes while the app is open. Show them yourself:
PushPal.onNotificationReceived.listen((n) {
localNotifications.show(n.hashCode, n.title, n.body, details, payload: jsonEncode(n.data));
});
// …and on tap of that local notification:
PushPal.trackNotificationOpen(jsonDecode(payload!), buttonId: response.actionId);Android & iOS project setup
<!-- android/app/src/main/AndroidManifest.xml, inside <application> (optional) -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification" />iOS checklist:
1. Xcode → Runner → Signing & Capabilities → + Push Notifications
2. + Background Modes → tick "Remote notifications"
3. Apple Developer → Keys → create an APNs key (.p8)
4. Upload it to Firebase (Project settings → Cloud Messaging) — and optionally
to PushPal (Settings → Push providers) for direct APNs delivery
(then attachFirebase(…, useApnsToken: true))Users & identity verification
// After your own login. signature = HMAC from YOUR server (see Identity verification).
await PushPal.identify(user.id, signature: sig, attributes: {'first_name': 'Sita', 'plan': 'pro'});
await PushPal.track('level_up', properties: {'level': 4});
await PushPal.trackPurchase(4.99, currency: 'USD', productId: 'gems_500');
await PushPal.setTags({'theme': 'dark'});
await PushPal.logout(); // sign-out
await PushPal.unsubscribe(); // durable opt-out (only your server can lift it)identify() links a device to a person (contact) with typed attributes, email and phone. Anyone holding your client key could claim any user id, so sign it: your backend computes hex(HMAC-SHA256(identitySecret, userId)) (Settings → Keys & identity) and the app passes it as signature. Turn on “Require signed identify()” before launch — unsigned calls are then rejected.
// Your backend: sign the user id for identify()
import { signIdentity } from '@pushpal/node';
const signature = signIdentity(process.env.PUSHPAL_IDENTITY_SECRET, user.id);
// any language: hex(HMAC_SHA256(identitySecret, userId))unsubscribe() is durable: it survives sign-in, reinstall and new devices. Only a server key (PATCH /users/:id { "unsubscribed": false }) lifts it. Use logout() for sign-out.
Events & purchases
install and session_start are automatic. track(name, properties) feeds segments (“did / did not do X in N days”), journeys and in-app triggers. trackPurchase(amount) records revenue, attributed to the last campaign opened within 24 hours and to the install's tracking link.
Server-side: POST /users/:id/events with { "event": "renewed", "revenue": 9.99, "currency": "USD" }.
In-app messages & inbox
// In-app messages and inbox
PushPal.setTrigger('cart_items', 3); // local only, used by in-app rules
final inbox = await PushPal.inbox();
await PushPal.inboxMarkRead(inbox.map((m) => m.id).toList());
// Attribution links (from your deep-link handler)
await PushPal.handleDeepLink(uri);Rule-based messages (Dashboard → In-app): banner, modal or full screen with up to two buttons (dismiss, open route/URL, ask for push permission). They show on a trigger event (default session_start), when local trigger rules match, within an optional server-side audience, and obey a device-side frequency cap (“at most 2 times per 7 days”). Rules are cached on the device for 5 minutes. Paused messages aren't counted as impressions.
Live messages: send a campaign with the “Live in-app” channel and every open app receives it within a second over server-sent events.
Inbox: campaigns with the “In-app inbox” channel persist per person (or per device for anonymous users). inbox() / inboxMarkRead().
Web push
Settings → Push providers → Web push → Generate keys. Then:
<!-- 1. Copy https://push.neptechpal.com.np/sdk/pushpal-sw.js to your site root as /pushpal-sw.js -->
<!-- 2. Load the SDK -->
<script src="https://push.neptechpal.com.np/sdk/pushpal.js"></script>
<script>
PushPal.init({ appKey: 'pk_your_app_key' });
document.querySelector('#notify-me').onclick = () => PushPal.requestPermission();
PushPal.on('opened', (n) => n.route && (location.href = n.route));
</script>PushPal.identify('user-42', { signature, attributes: { plan: 'pro' } });
PushPal.track('signup');
PushPal.trackPurchase(9.99, { currency: 'USD' });
PushPal.inbox().then(render);
PushPal.setTrigger('pricing_visits', 3);Safari needs requestPermission() from a click. The SDK also renders in-app messages and live messages on the page. Try it on the live demo.
React Native
@pushpal/react-native — TypeScript, no native module. Works beside @react-native-firebase/messaging or expo-notifications.
import AsyncStorage from '@react-native-async-storage/async-storage';
import messaging from '@react-native-firebase/messaging';
import PushPal from '@pushpal/react-native';
PushPal.configure({ appKey: 'pk_…', platform: Platform.OS, storage: AsyncStorage });
await PushPal.registerPushToken(await messaging().getToken());
messaging().onTokenRefresh((t) => PushPal.registerPushToken(t));
messaging().onNotificationOpenedApp((m) => PushPal.trackNotificationOpen(m.data));
PushPal.onNotificationOpened((n) => n.route && navigation.navigate(n.route));Native iOS & Android
iOS (Swift Package, zero dependencies)
PushPal.configure(appKey: "pk_…")
// didRegisterForRemoteNotificationsWithDeviceToken:
PushPal.registerDeviceToken(deviceToken) // direct APNs (or registerFCMToken)
// userNotificationCenter(_:didReceive:):
PushPal.handleNotificationResponse(response)
PushPal.identify(user.id, signature: sig, attributes: ["plan": "pro"])Android (Kotlin, zero dependencies)
PushPal.init(this, "pk_…") // Application.onCreate
override fun onNewToken(token: String) = PushPal.registerToken(token)
PushPal.handleOpen(intent.extras) // launcher Activity
PushPal.identify(user.id, signature = sig)REST API
Base URL https://push.neptechpal.com.np/api/v1. Server endpoints use Authorization: Bearer sk_…; SDK endpoints use X-App-Key: pk_…. Errors are { "error": "…" } with 400/401/402 (plan limit)/404/409/429.
curl -X POST https://push.neptechpal.com.np/api/v1/notifications \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1234-shipped" \
-d '{
"title": "Your order shipped 🚚",
"body": "Hi {{ first_name | default: \"there\" }}, tap to track it.",
"route": "/orders/1234",
"audience": "USERS",
"externalUserIds": ["user-42"],
"transactional": true
}'import { PushPal } from '@pushpal/node';
const pushpal = new PushPal(process.env.PUSHPAL_SECRET_KEY);
await pushpal.send({
title: 'New lesson is live',
body: 'Chapter 4 just dropped.',
channels: ['PUSH', 'INBOX'],
audience: 'FILTERS',
filters: { attributes: [{ key: 'plan', op: 'eq', value: 'pro' }], lastSeenWithinDays: 30 },
localSendAt: '2026-11-01T09:00', // 9am in every user's timezone
});Server endpoints (sk_)
| POST /notifications | Send now / schedule / local-time; Idempotency-Key header makes retries safe |
| GET /notifications?limit= | Latest notifications with stats |
| GET /notifications/:id | One notification with stats |
| DELETE /notifications/:id | Cancel a scheduled or in-flight notification |
| POST /users | Create/update a person: externalUserId, email, phone, attributes (null removes), unsubscribed |
| GET /users/:id | A person and their devices |
| PATCH /users/:id | Update; { "unsubscribed": false } lifts a durable opt-out |
| DELETE /users/:id | Erase the person, devices, events, logs and inbox |
| POST /users/:id/events | Server-side event (with optional revenue) |
| POST /import | Bulk upsert up to 10,000 people |
| GET /messages?deviceId=|externalUserId=|campaignId= | Delivery log with raw provider responses |
SDK endpoints (pk_)
| POST /devices | Register/refresh: token, platform, channel (FCM|APNS|WEBPUSH), facts, tags, previousToken, clickId |
| POST /identify | token, externalUserId, signature, attributes, email, phone |
| POST /logout · /unsubscribe | Detach person · durable opt-out |
| POST /track · /purchase | Events and revenue |
| POST /events/open | token, campaignId, variant?, button? |
| GET /inbox · POST /inbox/read | Inbox |
| GET /inapp · POST /inapp/event | In-app rules · impression/click/dismiss |
| GET /live?key=&token= | Server-sent events for live messages |
| POST /deeplink | Attribute an open to a tracking-link click |
| GET /sdk/check | Setup checklist |
Audience filters
Used by segments, audience: "FILTERS", in-app messages and journeys. Every rule must match. Unsubscribed and permission-denied devices are always skipped.
{
"platforms": ["ANDROID", "IOS", "WEB"],
"loggedIn": true,
"lastSeenWithinDays": 7,
"notSeenForDays": 30,
"firstSeenWithinDays": 3,
"minSessions": 5,
"maxSessions": 100,
"appVersions": ["2.4.0"],
"locales": ["ne_NP", "en_US"],
"countries": ["NP", "IN"],
"testDevicesOnly": false,
"tags": [{ "key": "theme", "op": "eq", "value": "dark" }],
"attributes": [
{ "key": "plan", "op": "eq", "value": "pro" },
{ "key": "level", "op": "gte", "value": 3 }
],
"events": [
{ "name": "purchase", "op": "did_not", "withinDays": 30 }
]
}Tag ops: eq neq exists not_exists. Attribute ops: eq neq gt gte lt lte contains exists not_exists. Event ops: did did_not. Or describe the audience in English and let AI write the rules.
Message content
| title, body | Required (120 / 1000 chars). Liquid templates: {{ first_name | default: 'there' }}, filters default, upcase, downcase, capitalize, truncate |
| channels | ["PUSH"] default; add INBOX, LIVE, EMAIL, SMS |
| imageUrl | Big picture (Android, iOS with a service extension, web) |
| route / url | Delivered as data.route / data.url for deep links |
| buttons | Up to 3: [{ id, title, route?, url? }] — taps report the button id |
| data | Extra string key/values |
| emailSubject, emailHtml, smsText | Per-channel content (defaults derive from title/body) |
| variants, abTestPercent, abWinnerAfterMin | A/B test: split by weight, or test on a slice and send the winner (best open rate) to the rest |
Variables come from contact attributes, device tags and user_id email locale country app_version platform. Validate payloads with the push preview tool.
Scheduling & time zones
| scheduledAt | ISO time; omitted = now (out within ~5 s) |
| localSendAt | "2026-11-01T09:00" = 9:00 in each recipient's timezone (Pro+). Devices without an IANA zone use their UTC offset, then the app timezone |
| respectQuietHours | Default true: held during the app's quiet hours |
| transactional | Skips quiet hours and the frequency cap (OTP, order updates) |
| throttlePerMin | Spread a big send over time |
Frequency cap (Settings → General): at most N marketing pushes per device per X hours; extra sends are logged as SUPPRESSED and not charged.
Intelligent Delivery
Like OneSignal's Intelligent Delivery: instead of everyone at once, each device gets the push in the hour it usually engages. PushPal learns a best hour per device every hour from the last 90 days (notification opens weigh 3×, app sessions 1×). Devices without enough history use the app's best hour (or the start hour). The send runs for 24 hours from scheduledAt; a final sweep catches anything left. Pro plan and up.
{ "title": "Daily quiz is ready", "body": "5 questions, 2 minutes.", "intelligentDelivery": true }Receipts, goals & lift
| Confirmed delivery | Every push carries data.pushpal_msg + pushpal_base. The web service worker reports receipts automatically; apps call reportReceived(data) (Flutter does it in the foreground for you). Campaigns show “confirmed on device”. |
| goalEvent / goalWindowHours | Conversion = the device fired the event within N hours of receiving the push. |
| controlPercent | Hold out 1-50% (never sent, not charged). The campaign page shows recipient vs control conversion and the lift. |
| Uninstalls | When a provider says a token is dead, PushPal records an uninstall event (Analytics → Uninstalls) and removes the device. |
Topics (preference center)
Define topics in Settings → Topics (“offers”, “news”). Show a settings screen with PushPal.topics() and setTopic(key, on) (all SDKs; REST GET/POST /topics). Campaigns with topic skip devices that turned it off.
Templates & languages
Save composer content as a template, then send it from your backend with variables:
{ "templateId": "tpl_…", "templateData": { "order_id": "1234" }, "audience": "USERS", "externalUserIds": ["user-42"], "transactional": true }translations holds per-language content ({ "ne": { "title": "…", "body": "…" } }); each device gets the version for its locale. The composer can ✨ translate for you. Other options: silent (data-only, wakes the app), ttlSeconds (expire undelivered pushes), badge (iOS), recurrence DAILY/WEEKLY with recurUntil.
Email & SMS
Settings → Email & SMS. Email goes through your SMTP server (verified on save; every email has a one-click unsubscribe). SMS goes through your gateway: Sparrow SMS, Aakash SMS, Twilio or any HTTP API with a body template. Email and SMS are addressed to a person (email / phone on the contact), push to a device.
Journeys
Automations with a trigger (install, an event, or N days inactive), optional entry rules, and steps: wait, send (push / inbox / email / SMS), exit if event (goal reached) and end. Each send step has its own stats, opens and revenue. Example: install → wait 1 day → exit if purchase → push “Still thinking it over?”.
Attribution links
Links (https://push.neptechpal.com.np/l/your-slug) send visitors to the Play Store, App Store or web, then attribute the install and later purchases: click → install → purchase, with revenue per link. Exact when the click id reaches the app — the Play Store install referrer (setInstallReferrer) or a deep link (handleDeepLink); inferred from IP + platform within 24 hours otherwise. First touch wins. No second SDK.
Webhooks
Events: campaign.sent notification.opened device.registered contact.unsubscribed event.tracked purchase link.clicked install.attributed inapp.clicked journey.completed. Body: { id, type, appId, createdAt, data }. Retries with backoff (1 m, 5 m, 30 m, 2 h, 12 h); endpoints pause after 50 straight failures.
import { verifyWebhook } from '@pushpal/node';
app.post('/pushpal-webhook', express.raw({ type: '*/*' }), (req, res) => {
const ok = verifyWebhook(req.body.toString(), req.header('PushPal-Signature'), process.env.PUSHPAL_WEBHOOK_SECRET);
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body); // { id, type, appId, createdAt, data }
res.sendStatus(200);
});Delivery errors
Every attempt is logged with the provider's raw response (Delivery log, device and person pages). Dead tokens are deleted automatically. Common codes:
| messaging/registration-token-not-registered | App uninstalled or token rotated — device removed |
| messaging/invalid-argument | Bad payload or a token from another Firebase project |
| messaging/mismatched-credential | The token belongs to a different Firebase project than your key |
| BadDeviceToken (APNs) | Sandbox token sent to production (or vice versa) — check the Sandbox switch |
| http_410 (web push) | Subscription expired — device removed |
| frequency_cap / quota_exceeded | Suppressed by your cap or plan limit (not charged) |
Click ✨ Explain on any failure for a plain-language fix.
AI
PushPal AI writes message copy from a prompt, turns plain-English audience descriptions into filter rules, and explains delivery errors. It runs on Cloudflare Workers AI (Qwen). Calls are pooled per account per day by plan; add your own Cloudflare key in Billing for unlimited calls.
Moving from OneSignal
- Upload the same Firebase service account (and APNs key) you gave OneSignal — your users' tokens stay valid.
- Export users from OneSignal (CSV) and import them in People → Import;
external_idmaps to the user id, other columns become attributes. - Ship an app update with the PushPal SDK. Devices register themselves on first launch; call
identify()where you calledOneSignal.login(). - Map concepts: player → device, external id → user id, data tags → tags/attributes, segments → segments, in-app → in-app, journeys → journeys.
- Run both SDKs for one release, then remove OneSignal.
See the full comparison.
Security & keys
- Client keys (
pk_) can only register devices and report their own events; server keys are rejected by the SDKs. - Server keys (
sk_) are stored as a SHA-256 hash, shown once, rotatable instantly. - Firebase, APNs, SMTP, SMS and webhook secrets are sealed with AES-256-GCM under a versioned keyring; keys rotate with one command and no downtime.
- Identity verification (HMAC) stops user-id impersonation.
- Every query is scoped to the app; cross-tenant isolation tests run on every deploy.
- Six team roles: owner, admin, developer, marketer, analyst, viewer.
- Report vulnerabilities to security@neptechpal.com.np.
Retention & erasure
Delivery logs and events are kept for the app's retention window (default 90 days, 7–730). DELETE /users/:id (or “Erase” on the person page) deletes the person, devices, events, delivery logs and inbox. Deleting an app deletes all of its data.
Plans & limits
One send = one message to one recipient on one channel (push per device; email/SMS/inbox per person). Suppressed and opted-out recipients aren't charged. See pricing; over the ceiling, sends are refused (status 402 at the API, SUPPRESSED quota_exceeded in logs).
Production checklist
- Firebase key uploaded and verified; iOS APNs key in Firebase (or direct in PushPal with the right Sandbox setting).
runSetupTest()passes in a debug build.- Identity verification on; your backend signs user ids.
- Quiet hours, timezone and frequency cap set.
- Test device marked; every campaign tested first.
- Server key only on your server; webhook signatures verified.