Overview
Explicit, developer-triggered hook for registering and unregistering the current user’s device for push notifications. Unlike auth-token restoration, requesting OS or browser push permission is a deliberate action and should happen in response to a user gesture (for example, when they opt in to notifications in your settings screen) — not silently on mount. Calling it once per device is enough. Mounting the hook (which apps do on any screen holding a notification setting) also installs the SDK’s device-token rotation handling, so bindings stay correct without the app re-registering on every launch. The hook accepts a platform adapter that abstracts the platform-specific permission and token-retrieval steps. Three adapters are available out of the box:Usage Example
Parameters
PushTokenAdapter
required
A platform adapter implementing
requestPermission() and getDeviceIdentifier(), and optionally subscribeToIdentifierChanges(). Import one of the three built-in adapters or implement your own.Returns
() => Promise<boolean>
Requests OS or browser permission, retrieves the device token or subscription, and registers it with the server. Returns
true on success, false if permission was denied or the adapter could not produce an identifier (both are expected outcomes, not errors). Throws when the server call fails. It also records this device’s identifier and enables push for the stored accounts — see What register() does beyond the API call.() => Promise<void>
Unbinds the active account from this device and records that as a durable preference — see
unregister() is a preference, not a logout step. A no-op if the adapter returns no identifier.boolean
true while register() is in progress.boolean
true while unregister() is in progress.The hook requires an authenticated user —
register() and unregister() throw immediately if no user session is available.What register() does beyond the API call
On success it also:
- stores this device’s identifier durably, so account-management paths that hold no adapter (removal, sign-out, the per-account toggle) can still unbind push;
- records push as enabled for the active account, because “push is on for this account on this device” is a sticky preference rather than a one-shot call;
-
records it for every other stored account that has never expressed a preference. Permission is a device-level grant, so
register()speaks for the accounts that were never asked — but never over one the user deliberately silenced, which keeps itsfalse. Turn individual accounts back off withuseAccountPushToggle; -
marks accounts as needing a re-bind, so each is bound the next time the user switches into it, using the live session that switch establishes. Two things put an account in that state, and they are different sets:
- the device identifier this call brought differs from the stored one, which makes every opted-in background account’s binding stale — all of them are marked;
- the identifier is unchanged but this call just flipped some accounts from “never asked” to enabled. Those are reported as push-enabled and have no binding, and only the active account was registered here — so exactly the newly flipped ones are marked, and accounts that were already working are left alone.
register()on the same identifier that flips nothing marks nothing.
unregister() is a preference, not a logout step
unregister() silences the active account on this device durably: the flag is stored in the account map and merged into the entry on every rebuild, so it survives switches and relaunches until register() or the per-account toggle turns it back on. It does not clear the stored device identifier — that is device state shared with every other account on the device, and clearing it would break their deregistration too. It does record the identifier it fetched, so a device with none stored acquires one here.
Platform Adapters
expoPushTokenAdapter (@sublay/expo)
Uses expo-notifications to request permission and retrieve the raw APNs/FCM device token directly (not the Expo relay token — Sublay dispatches to APNs and FCM using the project’s own credentials).
expo-notifications in your project.
reactNativePushTokenAdapter (@sublay/react-native)
Uses @react-native-firebase/messaging for both iOS and Android. On iOS, getAPNSToken() returns the raw APNs token; on Android, getToken() returns the FCM registration token.
@react-native-firebase/messaging and the native Firebase setup for your platform (GoogleService-Info.plist / google-services.json).
webPushTokenAdapter (@sublay/react-js)
Uses the browser’s Notification permission API and Push API. Fetches the project’s VAPID public key from the server (unauthenticated), then calls PushManager.subscribe() to create a Web Push subscription. Requires a service worker registered on your page.
false (without throwing) in environments where the Notification or Push APIs are unavailable (for example, SSR).
Custom Adapters
ImplementPushTokenAdapter from @sublay/core to use your own push library:
subscribeToIdentifierChanges is optional. Omit it and rotation simply falls back to the next register() — the pre-existing behaviour. Emitting null, or an identifier equal to the stored one, is a no-op.
canReadIdentifierWithoutPrompting and hasPermission are optional and work as a pair — see Reading the current identifier on mount. Set the flag only if getDeviceIdentifier() reads a value the platform already holds and can never raise a permission prompt; an adapter that omits hasPermission is not gated on one.
Automatic Token Rotation
Device push tokens rotate on reinstall, on an OS-initiated refresh, and after a backup restore, and every binding on the old token dies with it. The hook covers this itself: whenever it is mounted with an adapter that implementssubscribeToIdentifierChanges, it subscribes to that signal. On a change it unbinds the old identifier for the active account, records the new one, re-binds the active account, and marks every other push-enabled account as needing a re-bind — repaired on each account’s next activation rather than by spending its stored credential in the background. Silenced accounts are left silenced, and an account that never expressed a preference is neither marked nor bound.
The subscription is gated on the adapter alone, so an app whose adapter omits it subscribes to nothing. Nothing on this path prompts the user — receiving a refreshed token asks for no permission — and nothing on it exchanges a stored credential.
Reading the current identifier on mount (native)
Both native subscriptions are rotation-only, so a device whose token never rotates would never emit one — and every path that unbinds push is gated on having a stored identifier. To cover that, the built-in native adapters declarecanReadIdentifierWithoutPrompting, and the hook reads the current identifier once on mount and feeds it through the same path a rotation takes. An identifier equal to the stored one changes nothing, which is the common case.
Two conditions gate it, and both matter:
- The adapter must declare that reading cannot prompt.
expoPushTokenAdapterandreactNativePushTokenAdapterdo;webPushTokenAdaptermust not, because itsgetDeviceIdentifier()callspushManager.subscribe(), which can raise a permission prompt with no user gesture behind it. Web covers the same ground through its mount-emitting subscription instead. - The device must already hold OS notification permission, read through the adapter’s
hasPermission()— which reads the current status and never asks. A device token exists on both native platforms whether or not the user was ever asked (that is how silent push works), so “reading cannot prompt” is not on its own a reason to store one.register()cannot create a binding without a permission grant, so a device with no grant has no binding to discover from this release. An adapter that does not implementhasPermission()is not gated.
The permission gate is bypassed once per device
A grant proves nothing about the past. Turning notifications off in system settings does not invalidate an APNs or FCM token and does not remove a binding: the provider still accepts the notification and the OS simply does not display it, and the server prunes a token only on an uninstall or dead-token signal from the provider. So a device that registered on an SDK release which stored no identifier, and has since revoked permission, holds a live binding that the gate makes unreachable — no identifier means sign-out, account removal and the per-account toggle all no-op, and re-enabling notifications later delivers that account’s notifications to a device nobody is signed into. To close that, the first identifier read on a device ignoreshasPermission(). The SDK records that it has run and persists the record with the account map, so it happens once per install and every later read is gated normally. Two further conditions narrow it: the device must already have at least one stored account (an unbind is scoped to a refresh token’s subject, so with none stored there is nothing an identifier could ever unbind) and must have no identifier stored yet. A device whose account storage was empty the first time this SDK ran is marked as ineligible outright — it cannot be carrying a binding from an older release. A read that fails does not consume the one turn.
The cost is stated rather than hidden: a device that has accounts, never registered for push, and holds no permission does get an identifier stored by that single read, and its sign-outs then ask for an unbind that matches nothing.
The three built-in adapters are deliberately asymmetric, because the platforms are:

