Push
Connect Firebase, Apple Push, Expo, or web push notifications to FounderHQ Sequences.
Connect a push service so sequence push steps and push broadcasts reach your app or your website. FounderHQ sends through four services: Firebase Cloud Messaging, Apple Push Notification service (APNs), Expo, and Web push for browsers. Connect the one your app gets its device tokens from. For a website, connect Web push. About 5 minutes.
Before you start
You need the credentials of your push service: a Firebase service account, an Apple auth key, or an Expo project. Web push needs no credentials. Your app or site must also register each device with FounderHQ: a push goes to the devices of the contact. See Register a device. Then open Sequences in FounderHQ and add a push connection.
Connect Firebase
Paste: the whole service-account JSON file. In the Firebase console, open Project settings → Service accounts and generate a new private key. Paste the file's full contents, not just one field out of it.
FounderHQ stores it encrypted and uses it to mint short-lived access tokens. It never appears in any request you can inspect.
That is the entire setup. FCM covers both iOS and Android for a Firebase app, so one connection serves both.
Connect Apple (APNs)
Use this when your iOS app registers the device token it gets from Apple.
| Field | What to enter |
|---|---|
| Auth key | The .p8 file from Apple Developer → Keys. Create a key with the Apple Push Notifications service enabled. Paste the whole file |
| Key ID | 10 characters. It is also in the file name: AuthKey_KEYID.p8 |
| Team ID | 10 characters. It is under Membership details in Apple Developer |
| Bundle ID | The bundle ID of your app, exactly as in Xcode |
One connection is for one bundle ID. Add a connection for each app.
FounderHQ does not support .p12 or .cer certificates. Use a .p8 key.
A key that is limited to Sandbox reaches development builds only.
The test send needs a real device token.
Connect Expo
Use this when your app registers an Expo push token.
No field is required. Add the connection and it is ready.
The access token is only for projects with enhanced push security turned on. Leave it empty for all other projects.
Expo delivers through the Apple and Firebase credentials that are stored in your Expo project. FounderHQ does not need them.
The test send needs an ExponentPushToken[...].
Connect web push
Web push sends notifications to a browser: Chrome, Edge, Firefox, and Safari. There is no key to type. FounderHQ makes the key pair, and your site needs one small file and two calls.
See Web push for the full setup.
Register a device
A push goes to every device of the contact where push is on. Your app tells FounderHQ about a device with the FounderHQ events SDK. You do three things:
- Ask the person for the notification permission. The SDK never shows that prompt.
- Get the device token from Apple, Firebase, or Expo.
- Give the token to
registerPushToken.
Call identify when the person signs in. The device then belongs to that
contact. One token belongs to one contact at a time: a registration for
another contact moves the device.
A device is registered only for an identified person. The SDK always stores
the token, and it sends the registration after identify and until
reset(). For a guest it sends nothing, so you can call registerPushToken
on every app start.
A website does not pass a token. It calls registerWebPush, and the SDK
gets the subscription from the browser. See
Web push. The same rules apply: only for an
identified person, and nothing for a guest. A browser sends its registration
one time in each visit, not on each page.
While a person is identified, the SDK sends the registration again each time
the app starts, on a new token, and after setPushEnabled. FounderHQ uses
this to know which device a person used last.
The SDK checks the token before it stores it. It does not store or send a token that FounderHQ refuses, and it logs the reason. A token must have this shape:
| Provider | Token |
|---|---|
apns | 64 to 200 hex characters |
expo | ExponentPushToken[...] or ExpoPushToken[...] |
fcm | 8 to 4096 characters with no spaces |
iOS with APNs
An iOS app uses one of the two: APNs or Firebase. Do not register both tokens.
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
events.registerPushToken(deviceToken)
}The SDK reads the APNs environment from the build. A development build
registers as sandbox. An App Store or TestFlight build registers as
production. Pass options: .apns(environment: .sandbox) to set it
yourself.
iOS with Firebase
Use this in place of the APNs call above, not with it. Pass the FCM token
with .fcm(). Never pass the APNs token as a Firebase token: Firebase
refuses it.
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
guard let fcmToken else { return }
events.registerPushToken(fcmToken, options: .fcm())
}Android
FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
events.registerPushToken(token)
}
// In your FirebaseMessagingService
override fun onNewToken(token: String) {
events.registerPushToken(token)
}The SDK has no Firebase dependency. Your app gets the token and passes it.
Create the SDK client in Application.onCreate, not in an activity.
Firebase can start your app in a new process only to give it a new token,
and no activity exists then. The SDK supports one process: do not put the
messaging service in a separate android:process.
A registerPushToken call that changes nothing sends no second registration
in the same app start. With the same provider, a call that gives no appId
or no environment keeps the stored value, so onNewToken can pass only
the token.
React Native and Expo
import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import { registerExpoPushToken } from "@founderhq/events-react-native/expo";
// An Expo push token goes through the Expo push service.
registerExpoPushToken(
events,
await Notifications.getExpoPushTokenAsync({
projectId: Constants.expoConfig?.extra?.eas?.projectId,
}),
);
// A native token goes to APNs on iOS and to Firebase on Android.
registerExpoPushToken(events, await Notifications.getDevicePushTokenAsync());Use one of the two calls, not both. Always pass projectId to
getExpoPushTokenAsync. A development build or a store build cannot always
find the project without it, and the call then fails.
Run the call on every app start. That is how the SDK gets a token that changed.
Notifications.addPushTokenListener tells you when the token changes while
the app is open. It always gives a native token. Use it only when you
register a native token:
// Only for an app that registers the native token.
Notifications.addPushTokenListener((token) => {
registerExpoPushToken(events, token); // provider "apns" or "fcm"
});If you register an Expo push token, do not pass the token of this listener
to the SDK. It would replace the Expo device with a native one. Call
getExpoPushTokenAsync again in the listener and register that token.
With @react-native-firebase/messaging, pass the token yourself:
events.registerPushToken({
token: await messaging().getToken(),
provider: "fcm",
});
messaging().onTokenRefresh((token) => {
events.registerPushToken({ token, provider: "fcm" });
});From your backend
Send the same event to the server events API with a secret key. The key must be allowed to update contacts.
curl https://i.getfounderhq.com/api/events \
-H "Authorization: Bearer fhq_sk_XXXX" \
-H "Content-Type: application/json" \
-d '{
"contact": { "externalId": "user_42" },
"name": "$push_device_registered",
"properties": {
"$push_token": "DEVICE_TOKEN",
"$push_provider": "fcm",
"$push_platform": "android"
}
}'| Property | Required | Values |
|---|---|---|
$push_token | yes | The device token. For APNs, the lowercase hex of the token |
$push_provider | yes | fcm, apns, or expo |
$push_platform | yes, except for apns | ios, android, or web |
$push_app_id | no | The bundle ID, package name, or Expo project |
$push_environment | no | sandbox or production. APNs only |
$push_enabled | no | false stops pushes to this device |
$push_permission | no | authorized, denied, provisional, or not_determined |
To remove a device, send $push_device_removed with $push_token.
FounderHQ does not keep the token with the event. It is stored only on the device record of the contact.
Turn push off for a device
| You call | What happens |
|---|---|
setPushEnabled(false) | The device stays registered, and gets no pushes |
setPushEnabled(true) | Pushes go to the device again |
unregisterPushToken() | The device is removed from the contact, and the SDK forgets the token |
Use setPushEnabled for a notification switch in your app settings.
The browser SDK has the same three calls. In a browser,
unregisterPushToken() also ends the browser's FounderHQ push subscription.
In a browser, setPushEnabled is kept in the SDK's storage. A call before
init, in cookieless mode, or while analytics consent is not granted does
nothing. Call it again after init and after consent is granted.
Logout
Call reset() when the person signs out. The SDK first removes the device
from that contact, then it clears the identity. The next person who signs in
on the phone does not get the first person's pushes.
The SDK keeps the token on the phone. The signed-out guest gets no pushes,
also when your app calls registerPushToken again. The next identify
registers the device for the person who signs in.
reset() also clears the setPushEnabled switch. The next person starts
with push on, unless your app sets the switch again.
If the phone is offline at logout, the SDK keeps the removal. It sends the removal first when the app next starts, before any new registration.
A person who opted out of analytics still gets the removal: reset() and
unregisterPushToken() send it in all cases.
A website is different on this point. When a visitor refuses analytics
consent (consent("denied")), the browser SDK clears its storage and sends
nothing more, so it cannot send a removal. It ends its own push subscription
in the browser instead, so the browser gets no more pushes. FounderHQ shows
the browser as no longer reachable after the next push to it.
optOut() in the browser SDK does not end the subscription. It only stops
what the SDK sends, and the browser continues to get pushes. To stop pushes
too, call unregisterPushToken() before optOut().
unregisterPushToken() also works without consent. It sends nothing. It
ends only a subscription that the SDK made: one on the
founderhq-push-sw.js worker, or one made with the key that the page gave
to registerWebPush on this page load. A subscription from another push
product on your site stays.
Choose which devices get a message
A push step in a sequence and a push broadcast have Devices.
Devices shows three tiles: iPhone, Android, Browser. All three are on at the start. Click a tile to turn it off. One tile always stays on. iPhone includes iPad.
When your only push connection is a REST API, Send via shows in place of Devices. Your API decides which devices get the push.
The kind is the device, not the service that reaches it. An iPhone that registered through Firebase is iPhone.
A person who has no device of a kind that is on does not get the message. This is not an error: the broadcast still sends and the sequence still publishes.
Under Which of their devices, Last used only then sends to the device that the person used last among the kinds that are on.
Choose which connections send it
When you have two or more push connections, Select specific connections shows under Devices. Most apps do not need it. Without it, every push connection that is connected sends, and a connection that you add later sends too.
Click Select specific connections to see the list. It shows one box for each connection: two Firebase projects are two boxes. All boxes are checked at the start. Clear a box, and only the checked connections send. A connection that you add later then does not send until you check it. One box always stays checked. Use all goes back to every connection.
The two choices apply together. A device gets the message when its kind is on and its connection is checked. Each device gets the message through the connection of its own service.
A REST API connection is a connection too. It gets one request for each person: the token of the device that the person used last (when there is one) and the ID of the person in your system. Your API decides which devices it sends to, so Devices does not change this request. When only Android is on, your REST API still gets a request for each person.
A person can get the message twice
When an app connection and a REST API connection both send a message, a person that FounderHQ knows by a device and that your API knows too gets the push from both. To prevent this, click Select specific connections and keep only one of them checked.
Last used only applies to the devices of your app connections. A REST API connection always gets its one request for each person.
A connection that you remove or turn off shows as No longer connected in the list. The message still sends through the other checked connections. When no checked connection is connected, the broadcast does not send and the sequence does not publish until you choose again.
What a push message carries
A push template renders a title and a body. Both are required — a push with either one empty fails before it leaves FounderHQ.
FounderHQ also attaches two keys of its own to the notification's data payload:
| Key | What it is |
|---|---|
fhqOutboundMessageId | The ID of the message. The SDK reports it when the person opens the notification |
fhqLink | The link of the template, when it has one. Your app opens it |
fhqNotificationKey | The notification key, when the message has one of its own. See Replace or remove a notification |
If your template sets its own data values, FounderHQ's keys are added after them, so your data can never overwrite them.
Replace or remove a notification
Every push has a notification key: the name of its notification on the device. Use it to update a notification that is already showing, or to take it away when it no longer matters.
The key is any text you choose, such as order-1042 or chat-88. It can be
64 bytes at most. Set it as notificationKey when you
send from your server. A push with no key of its own
is named by its message ID, so you can still remove it later.
Replace
Send another push with the same key. The device shows one notification with the new text, not two. "Your order is packed" becomes "Your order has shipped".
| Device | What happens |
|---|---|
| iPhone and iPad | The new push replaces the old one in Notification Center |
| Android | The new push replaces the old one in the system tray |
| Browser | The new notification replaces the old one |
| A device on an Expo push token | Not replaced: Expo has no replace key. Both notifications show |
An iPhone updates the notification quietly. It may not make a sound again.
Remove from your app
Call the SDK when the person has seen what the notification is about: they opened the order, the chat, or the screen. Pass the notification key, or the message ID for a push with no key.
// iOS
events.dismissPushNotification(key: "order-1042")// Android
events.dismissPushNotification("order-1042")// Expo
import * as Notifications from "expo-notifications";
import { dismissExpoPushNotification } from "@founderhq/events-react-native/expo";
await dismissExpoPushNotification(Notifications, "order-1042");// Website
await founderhq.dismissPushNotification("order-1042");This runs on the device the person is using. It does not remove the notification from their other devices.
Remove from your server
Ask FounderHQ to remove a notification from the person's phones and tablets, wherever they are. Use it when the notification stops being true: the order was cancelled, the person replied on another device, the offer ended.
curl -X POST "https://app.getfounderhq.com/api/v1/push/remove" \
-H "Authorization: Bearer $FOUNDERHQ_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "to": { "externalId": "user_42" }, "notificationKey": "order-1042" }'To remove the notification of one message you sent, give its ID in place of
the person and the key: { "messageId": "..." }. The fields are on
Remove a push notification.
FounderHQ sends a silent push to each app device of the person. Your app hands it to the SDK, and the SDK removes the notification. Add this once:
iOS. Turn on the Remote notifications background mode, then:
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
if events.handleRemoteNotification(userInfo: userInfo, completion: {
completionHandler(.noData)
}) { return }
// Your own handling.
completionHandler(.noData)
}Android. Call handlePushMessage first in onMessageReceived. The
example under Report opens shows it.
Expo. Pass each notification your app receives to the SDK. It answers
true for a removal:
import * as Notifications from "expo-notifications";
import { handleExpoPushRemoval } from "@founderhq/events-react-native/expo";
Notifications.addNotificationReceivedListener((notification) => {
void handleExpoPushRemoval(Notifications, notification);
});That listener runs while your app is open. To remove a notification while
the app is in the background, call handleExpoPushRemoval from a
background notification task too.
What to expect:
- A removal is not a message. It is in no report, and it does not count toward how often a person is messaged.
- Android removes the notification as soon as the device is online.
- iPhone removes it when Apple delivers the silent push. Apple delays these when the battery is low or the app is used rarely, and does not deliver them to an app the person closed from the app switcher.
- A browser is not reached. A browser expects every push to show
something, so your page removes its own notification with
dismissPushNotification.
Report opens
The SDK sends $push_notification_opened with the message ID. FounderHQ
then shows the message as opened. The SDK reads only the two FounderHQ keys.
It never reads the title, the body, or your own data.
The SDK does not open the link. It gives the link to your app.
iOS reports the open by itself. Read the link in the configuration. The handler runs on the main actor:
FounderHQEventsConfiguration(
onPushNotificationOpened: { payload in
if let link = payload.link, let url = URL(string: link) {
UIApplication.shared.open(url)
}
}
)Create the SDK in application(_:didFinishLaunchingWithOptions:). iOS
reports the tap that started the app right after that method returns. An SDK
that is created later does not report that tap, and the handler does not run
for it.
Android gives the tap to your activity. Pass the intent from onCreate
and from onNewIntent:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Not after a rotation: the same intent comes back then.
if (savedInstanceState == null) reportPushOpen(intent)
}
// The activity was already open when the person tapped.
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
reportPushOpen(intent)
}
private fun reportPushOpen(intent: Intent?) {
val link = events.capturePushNotificationOpened(intent)
if (link != null) openLink(link)
}An intent that has no FounderHQ key is not a FounderHQ notification. The SDK
sends nothing for it and returns null, so you can pass every intent. The
call never throws: if Android cannot read the extras of an intent from
another app, the SDK sends nothing and returns null. It does the same when
the person starts the app from Recents, because the person did not tap again.
On Android, FounderHQ sends a notification part and a data part. The
result depends on the state of your app:
- Background or closed: the system tray shows the notification. A tap
starts your launcher activity, and the
datakeys are String extras on its intent. FounderHQ sets noclick_action, so the tap always goes to the launcher activity. - Foreground: Firebase calls
onMessageReceivedand shows nothing. Your app must build the notification. Put the two FounderHQ keys on its content intent as String extras, then the open is reported the same way.
class AppMessagingService : FirebaseMessagingService() {
private val events get() = (application as MyApplication).events
// Firebase can call this in a new process, with no activity.
override fun onNewToken(token: String) {
events.registerPushToken(token)
}
// Firebase calls this for a push that arrives while your app is in the
// foreground, and for a removal FounderHQ sends from the server.
override fun onMessageReceived(message: RemoteMessage) {
// A removal shows nothing: the SDK cancels the notification it names.
if (events.handlePushMessage(message.data)) return
val notification = message.notification ?: return
val launch = packageManager.getLaunchIntentForPackage(packageName) ?: return
launch.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
// The same two String extras that the system tray gives.
message.data["fhqOutboundMessageId"]?.let { launch.putExtra("fhqOutboundMessageId", it) }
message.data["fhqLink"]?.let { launch.putExtra("fhqLink", it) }
// The tag the system tray gives a FounderHQ push: a later push with
// the same key replaces this one, and dismissPushNotification finds it.
val key = FounderHQPushPayload.from(message.data).notificationKey
val tap = PendingIntent.getActivity(
this, key.hashCode(), launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
// Needs your notification channel and the notification permission.
NotificationManagerCompat.from(this).notify(
key,
0,
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(notification.title)
.setContentText(notification.body)
.setContentIntent(tap)
.setAutoCancel(true)
.build(),
)
}
}React Native and Expo pass each tap to the SDK. With
expo-notifications, pass the response of a tap, and the response that
started the app:
import { captureExpoPushNotificationOpened } from "@founderhq/events-react-native/expo";
const openPush = (response: Notifications.NotificationResponse | null) => {
const link = captureExpoPushNotificationOpened(events, response);
if (link) Linking.openURL(link);
};
// A tap while the app is open or in the background.
Notifications.addNotificationResponseReceivedListener(openPush);
// A tap that started the app.
Notifications.getLastNotificationResponseAsync().then(openPush);captureExpoPushNotificationOpened finds the FounderHQ keys for each token
type. With an Expo push token they are in content.data. With a native
token they are in trigger.payload on iOS and in
trigger.remoteMessage.data on Android. A response that is null, or that
is not a FounderHQ notification, sends nothing.
With @react-native-firebase/messaging, pass remoteMessage.data:
const openPush = (remoteMessage: { data?: object } | null) => {
if (!remoteMessage) return;
const link = events.capturePushNotificationOpened(remoteMessage.data);
if (link) Linking.openURL(link);
};
// A tap while the app is in the background.
messaging().onNotificationOpenedApp(openPush);
// A tap that started the app.
messaging().getInitialNotification().then(openPush);One tap can arrive from the listener and from the cold-start call. The SDK
reports a message ID one time: a repeat sends nothing and returns null, so
your app opens the link one time.
A website needs no code. founderhq-push-sw.js opens the link of the
notification and adds fhq_push with the message ID to it. If the push has
no link, it opens your home page. The SDK reads fhq_push when the page
loads, reports the open, and removes it from the address bar.
An open is counted only when these are true:
- The page that opens loads the SDK.
- The person is identified on that page. Call
identifythere. - The link is on your own site. A link to another site opens without
fhq_push, so the message ID does not go to that site.
What you can and cannot see
The push provider tells you it accepted the message. It does not tell you the phone showed it. A push message in FounderHQ is Sent, then Opened when the SDK reports the open. A sequence step cannot wait on "delivered" over push.
A dead device token — the app was uninstalled, or the token expired — fails permanently and is not retried. The device stays on the contact as no longer reachable. A new registration of the same token makes it active again.
Verify
- Build a sequence with one push step and run a contact with a registered device through it.
- The notification appears on the device with the title and body from your template.
- In FounderHQ, open Sequences → Message log. The message shows Sent.
Troubleshoot
The send fails saying the service account is invalid
The pasted value is not the service-account JSON. Paste the whole file. It
must contain project_id, client_email, and private_key.
The send fails saying the payload is missing title or body
The push template renders one of them empty. Check the variables it uses against the contact you are sending to.
One contact always fails while others work
That device token is dead. The app was removed, or the token rotated. Open
the app on the device: the SDK registers the current token again when the
app starts and your app calls registerPushToken.
A contact gets no push at all
The contact has no registered device, or push is off for each device. Check
that the app calls identify and registerPushToken, and that it did not
call setPushEnabled(false). A device is registered only after identify.
A contact gets the same push twice
An app connection and a REST API connection both send the message, and the contact is known to both. Open the step or the broadcast, select Select specific connections, and check only one of them. See Choose which connections send it.
A new device is not added
FounderHQ adds at most 5 new devices for one contact in one hour. A registration over that limit is not stored, and no other device of the contact changes. A device that the contact already has is never limited, so an app that registers on each start is safe. This occurs most often in a test that registers many tokens for one contact. Wait an hour, then start the app again: the SDK registers the token again.
registerWebPush returns permission_not_granted
The person has not allowed notifications, or blocked them. Call
Notification.requestPermission() from a click, then call registerWebPush
again. A person who blocked notifications must allow them in the browser's
site settings.
registerWebPush returns failed
service_worker_register_failed: the browser could not load the service
worker. Open /founderhq-push-sw.js on your site. It must return the file with the
importScripts line, over https. If your site has a Content Security
Policy, it must permit scripts from https://i.getfounderhq.com.
subscribe_failed: the worker is there, but the browser refused the
subscription. Try again later, and make sure that the browser is online.
other_push_subscription: the worker already has a push subscription from
another push product. A worker has only one subscription, and the SDK does
not end one that it did not make. Remove the other product's subscription
first, or use one push product for the site.
service_worker_exists: your site already has a service worker. See
If your site already has a service worker.
invalid_application_server_key: the key is not the public key of your web
push connection. Copy it again from Sequences settings.
A browser stopped getting pushes
Browsers end a subscription that is not used, and when the person clears
site data. The browser is then shown as no longer reachable. The next visit
with registerWebPush subscribes it again.
The SDK ignores the token
The token does not have the shape of its provider. See the table in
Register a device. The SDK log gives the reason. A
frequent cause is the wrong provider, for example a Firebase token that is
registered as apns.