Complete Guide: Push Notifications with Expo & APNs (2026)
- Growth
- Push
- Expo
- APNs
- Notifications
To send push notifications to an Expo app on iOS, install expo-notifications, make a development build with EAS (it can create the APNs key for you), ask the user for permission, get an Expo push token with getExpoPushTokenAsync({ projectId }), store it on your server, and POST messages to https://exp.host/--/api/v2/push/send. Android also needs FCM V1 credentials uploaded to EAS. Since SDK 53, remote push doesn't work in Expo Go on Android, so test in a development build.
This guide covers the Apple side, the Expo client code for SDK 58, a Supabase backend that sends pushes from a database webhook, and the App Review rules for notifications.
How Expo push works
- Your app asks iOS (or Android) for permission and receives a device token from APNs (or FCM).
getExpoPushTokenAsyncwraps that in an Expo push token that looks likeExponentPushToken[...].- Your server stores the token and later sends a message to Expo's push service.
- Expo forwards it to APNs or FCM using the credentials stored in your EAS project.
You can skip Expo's service and talk to APNs and FCM directly; expo-notifications works with either. The Expo service is simpler because one token format and one HTTP call cover both platforms.
What you need first
- A paid Apple Developer account. Expo's docs say it's required to generate push credentials.
- An EAS project (
eas initadds theprojectIdto your app config). - A device or simulator that supports push. Physical devices work. So does the iOS Simulator on Xcode 14 or later (macOS 13+, iOS 16+), and an Android Emulator with Google Play services. Older guides that say "simulators can't receive push" are out of date.
- A Firebase project for Android delivery through FCM.
- A Supabase project if you follow the backend part of this guide.
Step 1: The Apple side
Push Notifications capability
Your App ID needs the Push Notifications capability. When expo-notifications is in your config plugins, EAS Build syncs the capability to your App ID on the next build. To do it by hand, open your App ID under Identifiers, check Push Notifications, and save.
APNs key
The first time you make a development build, the EAS CLI asks whether to set up push notifications and generate a new Apple Push Notifications service key. Answer yes and EAS creates the key and stores it for you.
If you'd rather create the key yourself (to share it with another service, for example):
- In Certificates, Identifiers & Profiles, click Keys, then +. You need the Account Holder or Admin role.
- Name the key and check Apple Push Notifications service (APNs), then click Configure.
- Choose Sandbox & Production as the environment and Team Scoped (All Topics) as the key restriction, so one key covers development and App Store builds for all your apps.
- Confirm, then download the .p8 file. Apple doesn't keep a copy, and you can't download it again.
- Note the Key ID, and your Team ID from your membership details.
APNs keys don't expire, but they can be revoked. Expo's credentials docs note a limit of two APNs keys per Apple Developer account, so reuse one key across apps instead of making a new one per project. If you revoke a key, every app using it stops receiving pushes until you upload a replacement.
Step 2: Install and configure Expo
npx expo install expo-notifications expo-constants
{
"expo": {
"ios": { "bundleIdentifier": "com.yourcompany.yourapp" },
"android": {
"package": "com.yourcompany.yourapp",
"googleServicesFile": "./google-services.json"
},
"plugins": [
[
"expo-notifications",
{ "icon": "./assets/notification-icon.png", "color": "#ffffff" }
]
],
"extra": { "eas": { "projectId": "your-eas-project-id" } }
}
}
The Android notification icon should be a white shape on a transparent background. google-services.json comes from your Firebase project's Android app settings.
Step 3: Credentials on EAS
iOS
Register your test device first (eas device:create), then run eas build --profile development --platform ios and accept the push prompts. To attach a key you made yourself, run eas credentials, pick iOS and a build profile, choose Push Notifications: Manage your Apple Push Notifications Key, and upload the .p8 with its Key ID.
Android
- In the Firebase console, open Settings → Service accounts and generate a new private key (a JSON file).
- Run
eas credentials, choose Android → production → Google Service Account, then Manage your Google Service Account Key for Push Notifications (FCM V1), and upload the JSON.
That JSON is a server credential. Keep it out of git and out of the app bundle.
Step 4: Register for push in the app
// lib/push.ts
import { Platform } from 'react-native';
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
import { supabase } from './supabase';
// How to show a notification that arrives while the app is open.
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
export async function registerForPush(): Promise<string | null> {
// Android 13+ shows the permission prompt only after a channel exists.
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'Default',
importance: Notifications.AndroidImportance.DEFAULT,
});
}
let { status } = await Notifications.getPermissionsAsync();
if (status !== 'granted') {
({ status } = await Notifications.requestPermissionsAsync());
}
if (status !== 'granted') return null;
const projectId =
Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId;
if (!projectId) throw new Error('No EAS projectId. Run eas init.');
const { data: token } = await Notifications.getExpoPushTokenAsync({ projectId });
await saveToken(token);
return token;
}
async function saveToken(token: string) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return; // call registerForPush again after sign-in
const { error } = await supabase.from('push_tokens').upsert(
{ user_id: user.id, token, platform: Platform.OS },
{ onConflict: 'user_id,token' }
);
if (error) console.warn('Could not save push token', error);
}
Older snippets set shouldShowAlert in the handler. That option is deprecated; use shouldShowBanner and shouldShowList.
When to ask for permission
iOS shows the system prompt once. If the user says no, you can only send them to Settings. Ask at a moment when the benefit is obvious (after they create a reminder, follow a thread, or place an order), and explain it on your own screen first. For users who already declined, offer a button that calls Linking.openSettings() from react-native.
Opening the right screen on tap
With Expo Router, put a url in the notification's data and route to it. This is the pattern from Expo's docs, and it also handles a tap that launched the app from a closed state:
// app/_layout.tsx
import { useEffect } from 'react';
import * as Notifications from 'expo-notifications';
import { Slot, router } from 'expo-router';
function useNotificationObserver() {
useEffect(() => {
function redirect(notification: Notifications.Notification) {
const url = notification.request.content.data?.url;
if (typeof url === 'string') {
router.push(url);
Notifications.clearLastNotificationResponse();
}
}
const response = Notifications.getLastNotificationResponse();
if (response?.notification) redirect(response.notification);
const sub = Notifications.addNotificationResponseReceivedListener((r) =>
redirect(r.notification)
);
return () => sub.remove();
}, []);
}
export default function Layout() {
useNotificationObserver();
return <Slot />;
}
Step 5: Store tokens and send from Supabase
Token table
create table public.push_tokens (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
token text not null,
platform text not null check (platform in ('ios', 'android')),
created_at timestamptz not null default now(),
unique (user_id, token)
);
alter table public.push_tokens enable row level security;
create policy "Users manage their own push tokens"
on public.push_tokens
for all
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
One row per device, so a user with an iPhone and an iPad gets both.
Send from a database webhook, not from the client
A common mistake is an Edge Function that any signed-in app user can call with any userId. That lets anyone push to anyone. A safer pattern, and the one Supabase's own example uses: insert a row into a notifications table from trusted code, and let a Database Webhook call an Edge Function that holds the secrets.
create table public.notifications (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
title text,
body text not null,
url text,
created_at timestamptz not null default now()
);
alter table public.notifications enable row level security;
-- No insert policy for app users: only your server code writes here.
The function below adapts the example in Supabase's Sending Push Notifications guide to send to every device a user has:
// supabase/functions/push/index.ts
import { withSupabase } from 'npm:@supabase/server@^1'
interface Notification {
id: string
user_id: string
title: string | null
body: string
url: string | null
}
interface WebhookPayload {
type: 'INSERT'
table: string
record: Notification
schema: 'public'
}
// Triggered by a Database Webhook, which authenticates with a secret key.
// Deploy with verify_jwt = false.
export default {
fetch: withSupabase({ auth: 'secret' }, async (req, ctx) => {
const { record }: WebhookPayload = await req.json()
const { data: rows } = await ctx.supabaseAdmin
.from('push_tokens')
.select('token')
.eq('user_id', record.user_id)
// Expo accepts up to 100 messages per request.
const messages = (rows ?? []).slice(0, 100).map((r) => ({
to: r.token,
sound: 'default',
title: record.title ?? undefined,
body: record.body,
data: record.url ? { url: record.url } : {},
}))
if (messages.length === 0) return Response.json({ sent: 0 })
const res = await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${Deno.env.get('EXPO_ACCESS_TOKEN')}`,
},
body: JSON.stringify(messages),
}).then((r) => r.json())
return Response.json(res)
}),
}
- Create an Expo access token in your Expo account settings and turn on Enhanced Security for Push Notifications. After that, Expo rejects push requests that don't carry the token, so a leaked push token alone can't be used to message your users.
- Deploy and set the secret:
supabase functions deploy push, thensupabase secrets set EXPO_ACCESS_TOKEN=.... - In the dashboard, create a Database Webhook on
notificationsfor Insert events that calls thepushfunction, and add the auth header with the service key. - Insert a row into
notificationsto test.
Check push receipts
An ok ticket from Expo only means Expo accepted the message. About 15 minutes later, POST the ticket IDs to https://exp.host/--/api/v2/push/getReceipts (up to 1,000 IDs per request). If a receipt's details.error is DeviceNotRegistered, delete that token: the app was uninstalled or notifications were turned off. Expo's limit is 600 notifications per second per project; the official expo-server-sdk for Node handles batching, throttling and receipts if you send from your own server.
Step 6: Local notifications
Local notifications need no server and work in Expo Go. Triggers now take an explicit type:
import * as Notifications from 'expo-notifications';
// Once, one hour from now
await Notifications.scheduleNotificationAsync({
content: { title: 'Time for a break', data: { url: '/break' } },
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: Date.now() + 60 * 60 * 1000,
},
});
// Every day at 9:00
await Notifications.scheduleNotificationAsync({
content: { title: 'Daily check-in', body: 'Your list is waiting.' },
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DAILY,
hour: 9,
minute: 0,
},
});
Passing a bare Date or { hour, minute, repeats: true } as the trigger is the old format.
Actions and badges
import * as Notifications from 'expo-notifications';
await Notifications.setNotificationCategoryAsync('message', [
{ identifier: 'mark_read', buttonTitle: 'Mark as read', options: { opensAppToForeground: false } },
{
identifier: 'reply',
buttonTitle: 'Reply',
textInput: { submitButtonTitle: 'Send', placeholder: 'Type a reply' },
},
]);
Notifications.addNotificationResponseReceivedListener((response) => {
if (response.actionIdentifier === 'reply') {
const text = response.userText; // what the user typed
// send it to your backend
}
});
// Clear the app icon badge when the user opens the inbox
await Notifications.setBadgeCountAsync(0);
To use a category, send categoryId: 'message' in the push payload.
Testing
The quickest check is Expo's push notifications tool: paste the token from your development build and send. From a terminal:
curl -X POST https://exp.host/--/api/v2/push/send \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $EXPO_ACCESS_TOKEN" \
-d '{"to":"ExponentPushToken[xxxxxxxx]","title":"Test","body":"Hello"}'
Drop the Authorization header if you haven't turned on enhanced security yet.
Troubleshooting
getExpoPushTokenAsync throws about a missing projectId
Run eas init, then pass the ID explicitly as in Step 4.
No token on iOS, or pushes never arrive
The build may lack the push entitlement (add expo-notifications to plugins and rebuild), or EAS has no APNs key for this app (check eas credentials). Also check the receipt for an error.
Nothing arrives on Android in Expo Go
Expected since SDK 53. Use a development build.
Android pushes fail with a credentials error
Upload the FCM V1 service account key to EAS and make sure google-services.json is in the build.
Notifications don't show while the app is open
Call setNotificationHandler at startup, outside any component.
TOO_MANY_REQUESTS
You sent more than 600 notifications per second for the project. Throttle on your side or use expo-server-sdk.
App Review rules for notifications
- Guideline 4.5.4: push can't be required for the app to work, and shouldn't carry sensitive personal information.
- Promotional pushes need explicit opt-in through consent language in your UI, plus a way to opt out inside the app.
- Guideline 5.1.1: you can't lock features or rewards behind turning notifications on.
Where AppSetUpKit fits
AppSetUpKit doesn't ship notification UI, Supabase Edge Functions or analytics. Its setup wizard turns on the Push Notifications capability for your bundle ID through Fastlane and installs expo-notifications. For Convex backends, convex-push-setup.sh explains which of Apple's three .p8 keys you need (the APNs key, not the App Store Connect or Sign in with Apple key), scaffolds a convex/push.ts file using @convex-dev/expo-push-notifications, and walks you through uploading the APNs key with eas credentials. See what's in the kit.
More guides
Expo App Store Launch Checklist (2026)
Every step between a finished Expo app and Submit to App Review, in order, with the Apple rule or Expo doc behind each one. Checked against Apple's and Expo's documentation on November 3, 2026.
AppSetUpKit vs Expo Boilerplates: Free and Paid Starters Compared (2026)
Boilerplates give you app code, while AppSetUpKit sets up the accounts around it. We compare the free Expo starters with four paid ones (prices as listed on November 3, 2026) and say when a free option is enough.