Documentation

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.

Nothing is sent anywhere. A publishable key is saved in this browser. A secret key is kept only until you reload.

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.

FieldWhat to enter
Auth keyThe .p8 file from Apple Developer → Keys. Create a key with the Apple Push Notifications service enabled. Paste the whole file
Key ID10 characters. It is also in the file name: AuthKey_KEYID.p8
Team ID10 characters. It is under Membership details in Apple Developer
Bundle IDThe 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:

  1. Ask the person for the notification permission. The SDK never shows that prompt.
  2. Get the device token from Apple, Firebase, or Expo.
  3. 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:

ProviderToken
apns64 to 200 hex characters
expoExponentPushToken[...] or ExpoPushToken[...]
fcm8 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"
    }
  }'
PropertyRequiredValues
$push_tokenyesThe device token. For APNs, the lowercase hex of the token
$push_provideryesfcm, apns, or expo
$push_platformyes, except for apnsios, android, or web
$push_app_idnoThe bundle ID, package name, or Expo project
$push_environmentnosandbox or production. APNs only
$push_enablednofalse stops pushes to this device
$push_permissionnoauthorized, 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 callWhat 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:

KeyWhat it is
fhqOutboundMessageIdThe ID of the message. The SDK reports it when the person opens the notification
fhqLinkThe link of the template, when it has one. Your app opens it
fhqNotificationKeyThe 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".

DeviceWhat happens
iPhone and iPadThe new push replaces the old one in Notification Center
AndroidThe new push replaces the old one in the system tray
BrowserThe new notification replaces the old one
A device on an Expo push tokenNot 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 data keys are String extras on its intent. FounderHQ sets no click_action, so the tap always goes to the launcher activity.
  • Foreground: Firebase calls onMessageReceived and 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 identify there.
  • 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

  1. Build a sequence with one push step and run a contact with a registered device through it.
  2. The notification appears on the device with the title and body from your template.
  3. 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.

AI agent or LLM? Read this page as markdown

On this page