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.

  1. Sign in, create an app. You get a client key (pk_…, safe in apps) and can generate a server key (sk_…, backend only).
  2. Settings → Push providers: upload your Firebase service-account JSON (and optionally an APNs key, web push keys).
  3. Add the SDK (Flutter below), run the app, mark your phone as a test device, press “Send test”.

Firebase & APNs

bash
# Connect the app to your Firebase project (once)
flutter pub add firebase_core firebase_messaging url_launcher
dart pub global activate flutterfire_cli
flutterfire configure

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

pubspec.yaml
dependencies:
  pushpal_flutter:
    git:
      url: https://github.com/Neptechpal-pvt-ltd/pushpal_flutter.git
      ref: v1.0.0
lib/main.dart
import '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()));
}
dart
// 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

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

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

dart
// 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.

node.js
// 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

dart
// 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:

html
<!-- 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>
js
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.

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

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

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

bash
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
  }'
node.js
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 /notificationsSend now / schedule / local-time; Idempotency-Key header makes retries safe
GET /notifications?limit=Latest notifications with stats
GET /notifications/:idOne notification with stats
DELETE /notifications/:idCancel a scheduled or in-flight notification
POST /usersCreate/update a person: externalUserId, email, phone, attributes (null removes), unsubscribed
GET /users/:idA person and their devices
PATCH /users/:idUpdate; { "unsubscribed": false } lifts a durable opt-out
DELETE /users/:idErase the person, devices, events, logs and inbox
POST /users/:id/eventsServer-side event (with optional revenue)
POST /importBulk upsert up to 10,000 people
GET /messages?deviceId=|externalUserId=|campaignId=Delivery log with raw provider responses

SDK endpoints (pk_)

POST /devicesRegister/refresh: token, platform, channel (FCM|APNS|WEBPUSH), facts, tags, previousToken, clickId
POST /identifytoken, externalUserId, signature, attributes, email, phone
POST /logout · /unsubscribeDetach person · durable opt-out
POST /track · /purchaseEvents and revenue
POST /events/opentoken, campaignId, variant?, button?
GET /inbox · POST /inbox/readInbox
GET /inapp · POST /inapp/eventIn-app rules · impression/click/dismiss
GET /live?key=&token=Server-sent events for live messages
POST /deeplinkAttribute an open to a tracking-link click
GET /sdk/checkSetup 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.

json
{
  "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, bodyRequired (120 / 1000 chars). Liquid templates: {{ first_name | default: 'there' }}, filters default, upcase, downcase, capitalize, truncate
channels["PUSH"] default; add INBOX, LIVE, EMAIL, SMS
imageUrlBig picture (Android, iOS with a service extension, web)
route / urlDelivered as data.route / data.url for deep links
buttonsUp to 3: [{ id, title, route?, url? }] — taps report the button id
dataExtra string key/values
emailSubject, emailHtml, smsTextPer-channel content (defaults derive from title/body)
variants, abTestPercent, abWinnerAfterMinA/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

scheduledAtISO 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
respectQuietHoursDefault true: held during the app's quiet hours
transactionalSkips quiet hours and the frequency cap (OTP, order updates)
throttlePerMinSpread 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.

json
{ "title": "Daily quiz is ready", "body": "5 questions, 2 minutes.", "intelligentDelivery": true }

Receipts, goals & lift

Confirmed deliveryEvery 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 / goalWindowHoursConversion = the device fired the event within N hours of receiving the push.
controlPercentHold out 1-50% (never sent, not charged). The campaign page shows recipient vs control conversion and the lift.
UninstallsWhen 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:

json
{ "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?”.

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.

node.js
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-registeredApp uninstalled or token rotated — device removed
messaging/invalid-argumentBad payload or a token from another Firebase project
messaging/mismatched-credentialThe 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_exceededSuppressed 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

  1. Upload the same Firebase service account (and APNs key) you gave OneSignal — your users' tokens stay valid.
  2. Export users from OneSignal (CSV) and import them in People → Import; external_id maps to the user id, other columns become attributes.
  3. Ship an app update with the PushPal SDK. Devices register themselves on first launch; call identify() where you called OneSignal.login().
  4. Map concepts: player → device, external id → user id, data tags → tags/attributes, segments → segments, in-app → in-app, journeys → journeys.
  5. 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.