Skip to content
Private betaTreebars is letting teams in from the waitlist.Join the waitlist

Setting up push notifications

Apple and Google are configured separately, one channel each. A channel holds one provider's credentials, so reaching a user on both platforms means creating two — an Apple channel for their iPhone and a Google channel for their Android device.

Back to channels

Apple iOS (APNs)

Everything below comes from developer.apple.com/account, and needs a paid Apple Developer Program membership.

1Find your Team ID

Open Membership details. The Team ID is ten characters, shown at the top right of the page — something like A1B2C3D4E5.

2Create an APNs auth key

Go to Certificates, Identifiers & Profiles → Keys, press +, give the key a name, tick Apple Push Notifications service (APNs), then Continue and Register. The next screen shows the Key ID and offers the download.
The .p8 file downloads once. Apple will not let you download it again — if you lose it, the key has to be revoked and a new one created, and a team may only hold two APNs keys at a time. Save it somewhere durable before leaving the page.
One key covers every app in your team and both environments, so this is not something you need to repeat per app. The key also never expires: unlike a .p12 push certificate, which dies after a year and takes production with it, a .p8 stays valid until somebody revokes it. That is why the channel re-checks the key with Apple periodically rather than watching a date.

3Find your Bundle ID

Under Identifiers, open your App ID. The Bundle ID looks like com.example.app. Apple calls this the notification topic, which is the name the channel form uses it under.

4Enable Push Notifications in Xcode

The App ID needs the Push Notifications capability enabled, and in Xcode the target needs Signing & Capabilities → Push Notifications — that is what generates the aps-environment entitlement. Without the entitlement the app cannot obtain a device token at all, no matter how the channel is configured. Silent pushes additionally need Background Modes → Remote notifications.

5Sandbox and production

There is nothing to choose here — this step is background. Apple runs two hosts and a device token is only valid against the one matching the build it came from, but you no longer tell us which: the SDK reports the entitlement its build was signed with, and for a token that reports none we try the other host once and remember the answer.
Build
Run from Xcode (development signing)
Token type
sandbox
Apple host
api.sandbox.push.apple.com
Build
TestFlight
Token type
production
Apple host
api.push.apple.com
Build
App Store
Token type
production
Apple host
api.push.apple.com
This used to be a setting, and getting it wrong was expensive in a way nothing showed you: sending a sandbox token to the production host returns BadDeviceToken, which is indistinguishable from an uninstalled app, so a send retired the token. A channel on the wrong environment drained your registered devices quietly rather than failing loudly. A token is now only retired after both hosts have refused it.

6Create the channel

On the channels page, choose Push — Apple iOS (APNs), enter the Bundle ID, then the Key ID, Team ID and the .p8 file. Press Verify afterwards: that asks Apple whether the credentials are genuinely accepted, and needs no device to do it.

When Apple rejects something

BadDeviceToken
Two causes. A token from an iOS Simulator always fails this way — a simulator registers locally, Apple never sees the token, and no channel setting will reach one, so a real device is the only way to test a send. Otherwise it is an environment mismatch: check step 5, which is far more often the answer than a bad key. A token much longer than 64 characters is a strong sign you have a simulator’s.
InvalidProviderToken
The Key ID, the Team ID and the .p8 must all belong together. A Key ID from one key paired with another key’s file fails exactly like this. Also check the key has APNs enabled and has not been revoked.
MissingTopic
The channel has no Bundle ID set.
TopicDisallowed
The key is valid but not permitted for this Bundle ID — check for a typo, and that the App ID belongs to the same team as the key.
ExpiredProviderToken
The signed token was outside Apple’s one-hour window, which points at a clock problem on our side rather than your key. Retrying usually clears it.
Unregistered
The app is no longer installed on that device. Expected over time; real sends retire such tokens automatically.

Android (FCM)

From the Firebase console. Free — there is no paid tier to reach for.

1Open or create a Firebase project

If the Android app already uses Firebase, use that project. The app and this channel must be the same project, or every send is rejected as coming from the wrong sender.

2Generate a service account key

Project settings → Service accounts → Generate new private key. That downloads a JSON file containing project_id, client_email and private_key — the three values the channel signs with.
Upload the file rather than copying the fields out of it. private_key is stored on a single line with literal \n escapes; reading the file as JSON turns those into real newlines, and copying by hand does not — producing a key that looks perfectly correct on screen and cannot be decoded.
This is the FCM HTTP v1 API with a service account, not the old server key, which Google has retired. Keys have no expiry by default, though they can be disabled or deleted, and an organisation policy can impose a rotation period.

3Set the app up

The Android app needs google-services.json from Project settings → Your apps — a different file from the service account, and not the one to upload here. It also needs the Firebase Messaging SDK, and on Android 13 and later the POST_NOTIFICATIONS runtime permission.

4Create the channel

On the channels page, choose Push — Android (FCM) and upload the service account JSON. The form shows the project it belongs to before you save, which is the moment to catch the wrong file.
Firebase can also deliver to iOS by forwarding through APNs, which would mean uploading your .p8 to Google. We do not do that — Apple is configured as its own channel and we talk to Apple directly, which is one hop fewer and one fewer place your key is stored.

When Firebase rejects something

SENDER_ID_MISMATCH
The device token was issued by a different Firebase project. Check the app ships the google-services.json from the same project as this service account.
UNREGISTERED
The app is no longer installed, or the token has been replaced. Expected over time.
THIRD_PARTY_AUTH_ERROR
Firebase could not authenticate onward to APNs. This channel sends to Android; configure iOS as a separate Apple channel.
UNAUTHENTICATED
The service account was rejected — it may have been disabled or its key deleted. Generate a new private key and replace the credentials.

In your app

A channel can only reach a device that has told Treebars its push token. Three things, on every platform: ask for permission, hand over the token, and report a tap. Nothing here is needed to start the SDK — init is the same call as in the quickstart.

iOS

Ask once you have told the person why — not at launch. The answer is recorded for you. Then register with Apple, and pass the token on when Apple calls back.

AppDelegate.swiftswift
import TreebarsSDK
import UserNotifications

// 1. Ask, then register with Apple.
if await Treebars.requestPushPermission() {
    await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
}

// 2. Hand over the token Apple gives you.
func application(_ application: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    Treebars.registerPushToken(deviceToken)
}

// 3. Report a tap, a button press or a swipe-away, and perform the push's action.
//    Set UNUserNotificationCenter.current().delegate = self at launch.
func userNotificationCenter(_ center: UNUserNotificationCenter,
                            didReceive response: UNNotificationResponse) async {
    Treebars.handleNotificationResponse(response)
}

// 4. A silent push: from 0.7.0, how an in-app test shows within seconds on an open app.
//    Turn on Background Modes › Remote notifications for the target.
func application(_ application: UIApplication,
                 didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
    Treebars.handleRemoteNotification(userInfo: userInfo) ? .newData : .noData
}

requestPushPermission(provisional: true) asks for Apple's quiet trial instead: delivered to Notification Centre with no prompt. A push whose action is “open a screen” is handed to your own router:

swift
Treebars.setPushActionHandler { type, values in
    if type == "navigate" { router.open(values["screen"]) }
}

Images, GIFs and buttons need a Notification Service Extension, because iOS fetches nothing for a notification by itself. Add the extension target in Xcode and let the SDK fill it in:

NotificationService.swiftswift
import TreebarsSDK
import UserNotifications

class NotificationService: UNNotificationServiceExtension {
    override func didReceive(_ request: UNNotificationRequest,
                             withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        Task { contentHandler(await TreebarsNotificationService.enrich(request)) }
    }
}

Android

The SDK takes no Firebase dependency, so your app keeps its own FirebaseMessagingService and passes two things across: the token, and each message's data.

kotlin
// 1. Ask (Android 13 and later), from an Activity, once you have said why.
TreebarsPush.requestPermission(activity)

// 2. Hand over the token: at launch, and whenever Firebase issues a new one.
FirebaseMessaging.getInstance().token.addOnSuccessListener { Treebars.registerPushToken(it) }

class AppMessagingService : FirebaseMessagingService() {
    override fun onNewToken(token: String) = Treebars.registerPushToken(token)

    // A push with an image, buttons or a carousel arrives as data for the SDK to draw.
    // It answers false for any other message, which is then yours as before.
    override fun onMessageReceived(message: RemoteMessage) {
        if (TreebarsPush.handle(this, message.data)) return
        // your own handling
    }
}

// 3. Report a tap, in the Activity it opens. Safe on every launch: it does nothing
//    for an intent that carries no Treebars push.
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    intent.extras?.let { extras ->
        Treebars.trackNotificationOpened(extras.keySet().associateWith { extras.getString(it).orEmpty() })
    }
}

A push whose action is “open a screen” comes to your own router through TreebarsPush.setActionListener.

React Native

With React Native Firebase's messaging library, which is where the tokens and the messages come from. Each platform registers its own kind of token: Apple's on iOS, Firebase's on Android.

index.js — before AppRegistry.registerComponentts
import { getMessaging, onMessage, setBackgroundMessageHandler } from '@react-native-firebase/messaging';
import treebars from '@treebars/react-native-sdk';

// A rich push that arrives while the app is closed. Needs no init().
setBackgroundMessageHandler(getMessaging(), async (message) => {
  if (message.data) await treebars.handleRemotePush(message.data);
});

// And while it is open: from 0.7.0 this is how an in-app test shows within seconds.
onMessage(getMessaging(), async (message) => {
  if (message.data) await treebars.handleRemotePush(message.data);
});
After init, once you have said whyts
import { Platform } from 'react-native';
import {
  getAPNSToken, getInitialNotification, getMessaging, getToken,
  onNotificationOpenedApp, onTokenRefresh,
} from '@react-native-firebase/messaging';

const messaging = getMessaging();

// 1. Ask.
await treebars.requestPushPermission();

// 2. Hand over the token.
if (Platform.OS === 'ios') {
  const apns = await getAPNSToken(messaging);          // null until Apple has answered: try again shortly
  if (apns) await treebars.registerPushToken(apns.toLowerCase(), 'apns');
} else {
  await treebars.registerPushToken(await getToken(messaging), 'fcm');
  onTokenRefresh(messaging, (token) => treebars.registerPushToken(token, 'fcm'));
}

// 3. Report a tap: one that brought the app forward, and one that started it.
onNotificationOpenedApp(messaging, (message) => treebars.trackNotificationOpened(message.data));
getInitialNotification(messaging).then((message) => treebars.trackNotificationOpened(message?.data));

On iOS the answer to requestPushPermission is the person's. On Android it comes back at once, before they have answered the system prompt, so do not wait on it to register the token. Images and buttons on iOS need the same Notification Service Extension as a native app, above.

Web

Serve treebars-sw.js from the package at your site's root — or importScripts('/treebars-sw.js') from a service worker you already have — and subscribe from a click, since a browser refuses a prompt nobody asked for. The key is on your web push channel in the dashboard.

ts
button.addEventListener('click', async () => {
  await treebars.push.subscribe({ vapidPublicKey: 'B…' });
});

// Or explain first with the SDK's own soft prompt, and ask the browser only after a yes:
treebars.push.prompt({ vapidPublicKey: 'B…', text: 'Get told when your order ships?' });

The worker draws the push and reports a click by itself; there is nothing to wire for a tap.

A device that has never handed over a token is simply not in a push campaign's audience — there is no error to look for. If a test send reaches nobody, check first that the app called registerPushToken on that device, with the kind of token this channel's provider issues.