better-push
Delivery channels

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

EnableNotifications.tsx
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:

src/push.ts
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.

  1. Install the dev client on the phone and start Metro.

  2. Sign in, then tap Enable notifications and accept the system prompt. The prompt must follow a tap; never request permission on launch.

  3. Confirm the app shows the registered platform and provider, and that a new bp_device row exists with platform: "ios" / provider: "apns" (or android / fcm).

  4. 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.

  5. 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" },
    });
  6. The notification appears on the lock screen. Check the bp_delivery row for that device reads sent.

Then repeat with the app in the foreground to verify your expo-notifications handler renders it in-app.

  1. Open your inbox screen and confirm the notification is there and unread, then tap the push itself: with markReadOnPushClick on, useNotificationFeed() acknowledges it, including when the tap cold-started the app.
  2. Turn the type's push channel off in your preferences screen and send again. Nothing should arrive, and the bp_delivery row should read suppressed.

When nothing arrives

  • iOS, 400 BadDeviceToken on the delivery row - the gateway and the build disagree. A dev-client token needs production: false. See APNs Setup.
  • iOS, 400 DeviceTokenNotForTopic - topic is 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.json belongs 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.

On this page