
Push notifications
Pulse delivers push to three surfaces from a single campaign: Web Push (browsers and PWAs via VAPID), Apple Push (APNs for iOS/macOS), and Android (FCM/HMS). One audience, one schedule, one analytics view.
How it works
- The SDK registers a device token after the user grants permission and stores it on
device_tokens, scoped to the customer and the app slug. - You author a push from Campaigns → New → Push or from a Push node in a journey.
- The
push-dispatchedge function fans out to every active token, signs the request (VAPID JWT for web, APNs JWT for iOS, FCM HTTP v1 for Android), and records delivery + click events.
Pushes respect every safeguard the rest of Pulse uses: workspace-level frequency caps, per-channel quiet hours, suppression lists, and consent state.
Set up an app for push
Each App in Settings → Apps carries the credentials for one surface. Create one app per platform.
Web Push (browsers)
- Open your web app in Settings → Apps.
- Click Generate VAPID keys. Pulse writes the public key, private key, and contact subject onto
apps.vapid_public_key,apps.vapid_private_key,apps.vapid_subject. The private key never leaves the server. - Copy the
vapid_public_keyinto your front-end and pass it topulse.registerDevice():
// browser
await pulse.registerDevice({
platform: 'web',
token: subscriptionJSON, // PushSubscription.toJSON() from the browser
app_slug: 'consumer-web',
})The browser obtains subscriptionJSON via the Service Worker:
const reg = await navigator.serviceWorker.register('/sw.js')
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
})
await pulse.registerDevice({ platform: 'web', token: sub.toJSON(), app_slug: 'consumer-web' })iOS (APNs)
- Create an iOS app in Settings → Apps. Set the
bundle_id(e.g.com.yourco.app) and choose theapns_environment(developmentfor TestFlight,productionfor App Store). - Upload your APNs auth key (
.p8) under Integrations → Credentials. Pulse stores it in theprivate_secretsschema, accessible only to the dispatch function. - In your iOS app, request user permission and forward the device token from
application(_:didRegisterForRemoteNotificationsWithDeviceToken:):
Pulse.shared.registerDevice(platform: .ios, token: deviceTokenHex, appSlug: "consumer-ios")Android (FCM)
- Create an Android app in Settings → Apps. Set the package name as
bundle_id. - Upload the FCM service account JSON under Integrations → Credentials.
- In your Android app, get the FCM token and register it:
FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
Pulse.registerDevice(platform = "android", token = token, appSlug = "consumer-android")
}Authoring a push
From Campaigns → New campaign pick channel push. The editor exposes:
| Field | Notes |
|---|---|
| Title | Bold first line. Web/iOS show ~50 chars; Android shows ~65. |
| Body | Up to ~150 chars on most surfaces. Liquid variables are supported. |
| Image URL | Optional rich-media image. Web Push uses it as the notification icon; iOS uses it for UNNotificationAttachment; Android uses it as BigPictureStyle. |
| Click URL | Where the device should navigate when tapped. |
| App | Which app slug to dispatch through. Filters tokens to that surface. |
Scheduling and frequency
Every push (campaign or journey node) supports the same scheduling fields the rest of the engagement surfaces use:
- Starts at — earliest the dispatch may run. If unset, it dispatches immediately on activation.
- Ends at — latest the dispatch may run. After this, the campaign auto-archives even if not all tokens were delivered (rare).
- Display frequency:
once— fire one notification per device.daily/weekly— repeat at this cadence between starts at and ends at.persistent— for sticky/foreground notifications on Android; the OS keeps the notification visible until the user dismisses it.
Quiet hours and frequency caps still apply on top of these fields, so a daily push won't fire at 3am if your workspace has quiet hours configured.
Permission UX
Browsers and iOS show a one-time permission prompt; once denied, the OS won't show it again until the user manually re-enables in settings. Don't fire requestPermission() on first page load — wait for an action that justifies the prompt (sign-up, opt-in checkbox, "save for later").
Test mode
Pushes published in test mode only fan out to devices registered with a test SDK key (ppk_test_… / pk_test_…). Production devices are unaffected. Use this to verify payloads, deep links, and badge counts without spamming customers.
Troubleshooting
- Web Push:
410 Gone— the subscription is dead. Pulse marks the tokenrevoked_at = now()and stops sending. - iOS:
BadDeviceToken— usually a TestFlight build hitting the production APNs environment. Setapns_environment = 'development'on the app. - Android: empty deliveries — confirm the FCM service account JSON is uploaded and the SHA-256 fingerprint of the signing key matches the one in the Firebase console.