Test native mobile delivery
Register APNs, FCM or Expo push tokens from an Expo dev client and verify delivery on real hardware.
Web push is verified from a browser or an installed PWA
(Testing On Devices). The apns and fcm providers
need a native app, because only a native app can obtain a raw APNs or FCM
device token. This repo ships an Expo app at apps/native for exactly that,
built on @better-push/core/native.
This page is about the hardware and credentials side: dev clients, tokens, and what to check when nothing arrives. For the client API - the provider, the hooks, and Metro resolution - see React Native.
Why a development build
The app must be built as an Expo development build (a dev client), not run
inside Expo Go. Expo Go cannot deliver custom native push on iOS: it carries
Expo's own bundle id and entitlements, so a token obtained inside it does not
belong to your app and your apns-topic will not match it.
A dev client is a real build of your bundle id with your entitlements, installed
on the device, that still loads JavaScript from the Metro dev server. Building
one requires an Apple Developer account (iOS) and google-services.json
(Android), so it is an owner-run step. The toolchain, credentials, and
expo prebuild commands are in apps/native/README.md in the repo.
Simulators cannot receive push
The iOS Simulator cannot obtain an APNs device token, and an Android emulator without Play Services cannot obtain an FCM token. Native push verification needs a physical phone.
Raw device tokens by default
mode: "raw" - the default - calls Notifications.getDevicePushTokenAsync(),
which returns the native token: the raw APNs token on iOS and the raw FCM
registration token on Android. That exercises the real
apns and fcm providers, with no third
party between your server and Apple or Google.
mode: "expo" calls getExpoPushTokenAsync() instead and registers against the
expo provider, which relays through Expo's push service.
It is the right choice for an app distributed with Expo's push credentials.
The demo app at apps/native has a toggle for both, so one build proves both
paths against the same backend. Unregister before switching: the two modes
register different tokens, and the old device row otherwise stays behind.
Expo mode still needs a dev client
A token from inside Expo Go carries Expo's own project, not yours. Pass your
projectId and build a dev client, exactly as for raw tokens.
Register the device
import { usePushRegistration } from "@better-push/core/native";
function EnableNotifications() {
const { status, register } = usePushRegistration();
return (
<Pressable onPress={() => register({ deviceName: "Alice's iPhone" })}>
<Text>{status === "subscribed" ? "Enabled" : "Enable notifications"}</Text>
</Pressable>
);
}The hook does the permission request, the Android channel, the
getDevicePushTokenAsync() read, and the POST. It picks the pair by platform:
{ platform: "ios", provider: "apns" } or
{ platform: "android", provider: "fcm" }.
The endpoint is the same POST {basePath}/devices a browser calls. It rejects a
mismatched pair such as { platform: "android", provider: "apns" } with a
400, so a wrong pairing fails loudly at registration rather than at delivery.
Pointing the app at your backend
{API} is a base URL the app reads from its config, so the same build can talk
to a deployed backend or to your laptop:
- A deployed HTTPS URL is the simplest. Native push has no service-worker or
secure-context requirement of its own, but iOS App Transport Security blocks
plain
http://by default, so HTTPS avoids a whole class of confusion. - A LAN URL (
http://192.168.x.x:3000) works for fast iteration if you allow cleartext for that host in the dev build.
Your backend also has to authenticate the request. Better-push calls your
session resolver with the incoming Request, and the native client sends its
token as Authorization: Bearer, so accept one there in addition to your normal
cookie:
session: async (request) => {
const bearer = request.headers.get("authorization")?.replace(/^Bearer /, "");
const user = bearer
? await getUserFromToken(bearer)
: await getUserFromCookie(request);
return user ? { userId: user.id } : null;
},The demo app does this behind a clearly marked dev-only login endpoint; do not copy that shortcut into production auth.
The device walkthrough
Run this once per platform. It is the acceptance test for the mobile providers.
-
Install the dev client on the phone and start Metro.
-
Sign in, then tap Enable notifications and accept the system prompt. The prompt must follow a tap; never request permission on launch.
-
Confirm the app shows the registered platform and provider, and that a new
bp_devicerow exists withplatform: "ios"/provider: "apns"(orandroid/fcm). -
Background the app - go to the home screen and lock the phone. A push that only arrives with the app in the foreground proves nothing.
-
Send from your server:
await push.notify({ userId: user.id, title: "Hello from better-push", body: "Sent to a real device token.", data: { url: "/orders/123" }, }); -
The notification appears on the lock screen. Check the
bp_deliveryrow for that device readssent.
Then repeat with the app in the foreground to verify your
expo-notifications handler renders it in-app.
- Open your inbox screen and confirm the notification is there and unread, then
tap the push itself: with
markReadOnPushClickon,useNotificationFeed()acknowledges it, including when the tap cold-started the app. - Turn the type's push channel off in your preferences screen and send again.
Nothing should arrive, and the
bp_deliveryrow should readsuppressed.
When nothing arrives
- iOS,
400 BadDeviceTokenon the delivery row - the gateway and the build disagree. A dev-client token needsproduction: false. See APNs Setup. - iOS,
400 DeviceTokenNotForTopic-topicis not the bundle id of the installed build. - iOS,
403- the p8 key, key id, or team id is wrong. The token is fine; the device is not disabled. - Android,
messaging/registration-token-not-registered- the app was reinstalled or cleared its data. The device is disabled automatically; the app registers a fresh token on next launch. - Android, nothing at all - check that
google-services.jsonbelongs to the same Firebase project as the service account on the server.
Failure codes never throw; they land on the delivery row, which makes this debuggable from your own database. See Token Lifecycle.