Kyle Seyler
February 24, 2026

Push notifications are the most reliable way to reach users when they aren't looking at your app. In Expo, the expo-notifications library handles everything — permissions, tokens, local scheduling, and remote push — through a single API surface. But getting it right involves more than calling getExpoPushTokenAsync() and hoping for the best.
This guide covers the full implementation path: permissions, token registration, local vs. remote notifications, sending through Expo's push service or through Courier, and the real troubleshooting issues you'll hit along the way.
Before writing any notification code, you need three things:
Install the required libraries:
Copied!
npx expo install expo-notifications expo-device expo-constants
Then add the config plugin to your app.json (or app.config.js):
Copied!
{"expo": {"plugins": ["expo-notifications"]}}
This is the first decision, and many developers skip it. Local and remote notifications use the same expo-notifications library but serve fundamentally different purposes.
| Local Notifications | Remote (Push) Notifications | |
|---|---|---|
| Triggered by | The app itself, on the device | A remote server |
| Requires server? | No | Yes (your backend, Expo push service, or Courier) |
| Works offline? | Yes — scheduled locally | No — requires network to receive |
| Use cases | Reminders, timers, alarms, habit tracking | Messages, alerts, marketing, transactional updates |
| Token required? | No | Yes — ExpoPushToken or native device token |
| Works in Expo Go? | Yes (all SDK versions) | SDK 52 and earlier only; SDK 53+ requires dev build |
| App state | Scheduled while app is open; fires regardless of state | Delivered regardless of app state |
| API | scheduleNotificationAsync() | Expo Push API or direct FCM/APNs |
| Credential setup | None | FCM (Android) and APNs (iOS) credentials required |
Rule of thumb: If the trigger originates from outside the device (another user sent a message, a price changed, a deployment failed), you need remote push. If the trigger is something the device already knows about (a scheduled reminder, a countdown), use local.
Copied!
import * as Notifications from "expo-notifications";await Notifications.scheduleNotificationAsync({content: {title: "Daily standup",body: "Standup starts in 5 minutes",},trigger: {type: Notifications.SchedulableTriggerInputTypes.DAILY,hour: 9,minute: 55,},});
For Android 12+, exact scheduling requires the SCHEDULE_EXACT_ALARM permission. Add it to your app.json:
Copied!
{"expo": {"android": {"permissions": ["android.permission.SCHEDULE_EXACT_ALARM"]}}}
This is the single biggest friction point for developers implementing Expo push notifications for the first time. The permissions flow differs between iOS and Android, and getting it wrong means your users silently never receive notifications.
On iOS, the system shows a one-time permission dialog. If the user denies it, you cannot ask again — they must go to Settings manually. This is why you should never request permission on first launch without context.
On Android (API level 33+, Android 13+), notifications also require runtime permission (POST_NOTIFICATIONS). Older Android versions grant notification permission by default.
Copied!
import * as Notifications from "expo-notifications";import * as Device from "expo-device";import Constants from "expo-constants";import { Platform } from "react-native";async function registerForPushNotificationsAsync(): Promise<string | undefined> {// Android notification channels must be set before requesting permissionsif (Platform.OS === "android") {await Notifications.setNotificationChannelAsync("default", {name: "Default",importance: Notifications.AndroidImportance.MAX,vibrationPattern: [0, 250, 250, 250],lightColor: "#FF231F7C",});}// Push notifications only work on physical devicesif (!Device.isDevice) {throw new Error("Push notifications require a physical device");}// Check existing permission statusconst { status: existingStatus } = await Notifications.getPermissionsAsync();let finalStatus = existingStatus;// Only request if not already grantedif (existingStatus !== "granted") {const { status } = await Notifications.requestPermissionsAsync();finalStatus = status;}if (finalStatus !== "granted") {throw new Error("Notification permission denied. On iOS, the user must enable notifications in Settings.");}// Get the Expo push token, tied to your EAS projectconst projectId =Constants?.expoConfig?.extra?.eas?.projectId ??Constants?.easConfig?.projectId;if (!projectId) {throw new Error("Project ID not found. Ensure your app is configured with EAS.");}const token = (await Notifications.getExpoPushTokenAsync({ projectId })).data;return token;}
Never call requestPermissionsAsync() cold. On iOS, once the user dismisses the system dialog, you can't show it again. Use a soft prompt first:
Copied!
import { Alert } from "react-native";async function requestWithSoftPrompt(): Promise<string | undefined> {const { status } = await Notifications.getPermissionsAsync();if (status === "granted") {return registerForPushNotificationsAsync();}// Show a custom dialog before the system promptreturn new Promise((resolve) => {Alert.alert("Enable notifications?","We'll send you updates about order status and delivery. You can turn these off anytime in Settings.",[{ text: "Not now", style: "cancel", onPress: () => resolve(undefined) },{text: "Enable",onPress: async () => {const token = await registerForPushNotificationsAsync();resolve(token);},},]);});}
This pattern lets users decline your soft prompt without burning the one-time system dialog.
Once you have a user's ExpoPushToken, you can send push notifications through Expo's push service or through a provider like Courier.
The simplest path. Send a POST request to Expo's push endpoint:
Copied!
async function sendPushNotification(expoPushToken: string) {const message = {to: expoPushToken,sound: "default",title: "Order shipped",body: "Your order #4821 is on its way",data: { orderId: "4821", screen: "OrderDetail" },};const response = await fetch("https://exp.host/--/api/v2/push/send", {method: "POST",headers: {Accept: "application/json","Accept-Encoding": "gzip, deflate","Content-Type": "application/json",},body: JSON.stringify(message),});const ticket = await response.json();// Save ticket.data.id to check receipt later}
Rate limit: 600 notifications per second per project. Use the expo-server-sdk-node package for automatic batching and throttling in production.
Expo returns a push ticket immediately, but delivery isn't confirmed until you check the receipt (typically 15+ minutes later):
Copied!
# 1. Send the notificationcurl -X POST https://exp.host/--/api/v2/push/send \-H "Content-Type: application/json" \-d '{"to": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]","title": "Test","body": "Hello from the server"}'# 2. Check the receipt using the ticket IDcurl -X POST https://exp.host/--/api/v2/push/getReceipts \-H "Content-Type: application/json" \-d '{"ids": ["XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"]}'
For production backends, use the official SDK instead of raw fetch:
Copied!
import { Expo } from "expo-server-sdk";const expo = new Expo();const messages = [{to: "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]",sound: "default" as const,title: "Welcome",body: "Thanks for signing up!",},];const chunks = expo.chunkPushNotifications(messages);for (const chunk of chunks) {const ticketChunk = await expo.sendPushNotificationsAsync(chunk);// Store tickets for receipt checking}
The SDK handles chunking (max 100 notifications per request), retries, and error categorization automatically.
Your app needs to handle three scenarios: foreground notifications, background taps, and data extraction.
Copied!
import { useState, useEffect } from "react";import * as Notifications from "expo-notifications";// Configure how notifications appear when the app is in the foregroundNotifications.setNotificationHandler({handleNotification: async () => ({shouldPlaySound: true,shouldSetBadge: true,shouldShowBanner: true,shouldShowList: true,}),});export default function App() {const [expoPushToken, setExpoPushToken] = useState("");useEffect(() => {registerForPushNotificationsAsync().then((token) => setExpoPushToken(token ?? "")).catch(console.error);// Fires when a notification is received while app is foregroundedconst receivedSub = Notifications.addNotificationReceivedListener((notification) => {console.log("Received:", notification.request.content);});// Fires when user taps a notification (any app state)const responseSub = Notifications.addNotificationResponseReceivedListener((response) => {const data = response.notification.request.content.data;// Navigate based on notification dataif (data.screen) {// router.push(data.screen)}});return () => {receivedSub.remove();responseSub.remove();};}, []);// ... your UI}
| App state | What happens | Your control |
|---|---|---|
| Foreground | handleNotification callback fires; you decide whether to show it | Full — suppress, modify, or display custom UI |
| Background | OS displays the notification automatically | None — OS controls presentation |
| Terminated | OS displays the notification automatically | None — OS controls presentation |
If you're sending notifications across multiple channels (push, email, SMS, Slack), managing each provider's API independently gets complex fast. Courier unifies this — you send one API call, and Courier routes it to the right provider with failover, templating, and delivery tracking built in.
Here's how to wire Expo push notifications through Courier.
When a user registers for push notifications, store their ExpoPushToken in Courier's user profile:
Copied!
const COURIER_API_KEY = "YOUR_COURIER_API_KEY";async function registerTokenWithCourier(userId: string,expoPushToken: string) {await fetch(`https://api.courier.com/users/${userId}`, {method: "PUT",headers: {Authorization: `Bearer ${COURIER_API_KEY}`,"Content-Type": "application/json",},body: JSON.stringify({profile: {expo: {token: expoPushToken,},},}),});}
Call this after successful token registration in your app:
Copied!
const token = await registerForPushNotificationsAsync();if (token) {await registerTokenWithCourier("user-123", token);}
Now send notifications through Courier's API. The notification routes through Expo's push service automatically:
Copied!
// From your backendasync function sendViaCourier(userId: string) {const response = await fetch("https://api.courier.com/send", {method: "POST",headers: {Authorization: `Bearer ${COURIER_API_KEY}`,"Content-Type": "application/json",},body: JSON.stringify({message: {to: {user_id: userId,},content: {title: "Your order shipped",body: "Order #4821 is on the way. Tap to track.",},routing: {method: "single",channels: ["push"],},},}),});const { requestId } = await response.json();return requestId;}
This is where Courier adds real value. Send push first, fall back to SMS if unread after 30 minutes, then email as a last resort:
Copied!
const response = await fetch("https://api.courier.com/send", {method: "POST",headers: {Authorization: `Bearer ${COURIER_API_KEY}`,"Content-Type": "application/json",},body: JSON.stringify({message: {to: {user_id: "user-123",},content: {title: "Payment received",body: "We received your payment of $49.99. Receipt attached.",},routing: {method: "single",channels: ["push", "sms", "email"],},},}),});
Courier tries each channel in order and stops when one succeeds. You define the escalation logic once — Courier handles the provider calls, token resolution, and delivery tracking across all channels.
| Direct Expo Push API | Expo Push via Courier |
|---|---|
| Push only | Push + email + SMS + Slack + 50 more channels |
| Manual receipt polling | Automatic delivery tracking with status callbacks |
| No fallback logic | Configurable failover chains |
| Raw token management | User profiles with multi-channel tokens |
| Build your own analytics | Built-in delivery analytics and logs |
| 600/sec rate limit, self-managed | Courier handles batching and throttling |
You don't lose anything by routing through Courier — Expo push still handles the last mile to APNs and FCM. Courier sits between your backend and Expo's push service, adding routing, failover, and observability.
These are the issues that show up in every Expo push notification implementation. Bookmark this section.
ExpoPushToken not generatingSymptoms: getExpoPushTokenAsync() hangs indefinitely or throws an error.
| Cause | Fix |
|---|---|
| Running on simulator/emulator | Use a physical device. Push tokens cannot be generated on simulators. |
Missing projectId | Pass projectId explicitly: getExpoPushTokenAsync({ projectId }). Get it from Constants.expoConfig.extra.eas.projectId. |
| No internet connection | The device must reach Expo's servers to register for a token. Check connectivity. |
| Expo Go on SDK 53+ | Push notifications are not available in Expo Go from SDK 53. Build a development build. |
| iOS token fetch hangs | This is an Apple-side issue. Restart the device, check that airplane mode is off, ensure a SIM card is inserted, and try again. See Apple's troubleshooting guide. |
Push notifications do not work on iOS Simulator. This is a platform limitation, not a bug in your code. The simulator gained limited APNs support in Xcode 14, but it's unreliable and doesn't work in CI environments.
Workaround for testing: Use local notifications (scheduleNotificationAsync) on simulators to verify your client-side handling logic. Test remote push on a physical device.
Symptoms: Notifications arrive when the app is in the foreground but not when backgrounded or killed.
| Cause | Fix |
|---|---|
| Low priority | Set priority: "high" in your push message. Android deprioritizes normal and default notifications, especially with battery optimization enabled. |
| Missing notification channel (Android) | Create a channel with setNotificationChannelAsync() before sending. Android 8+ requires channels. |
| Battery optimization killing the app | Some Android manufacturers (Xiaomi, Huawei, Samsung) aggressively kill background apps. Direct users to dontkillmyapp.com for device-specific instructions. |
| Content-available not set (iOS) | For silent/data-only notifications, set content-available: 1 in the APNs payload. Without visible content (title/body), iOS may not wake your app. |
| Expired credentials | APNs push keys don't expire, but p12 certificates do (after 1 year). Check your credentials with eas credentials. |
This almost always means credentials are misconfigured.
eas credentials to generate one.DeviceNotRegistered errorThe user uninstalled the app or revoked notification permissions. Stop sending to this token and remove it from your database.
Android requires notification icons to be monochrome white on a transparent background. If your icon has color or a non-transparent background, Android renders it as a solid square.
Here's a complete, copy-paste-ready component that handles registration, sending, and receiving:
Copied!
import { useState, useEffect } from "react";import { Text, View, Button, Platform } from "react-native";import * as Device from "expo-device";import * as Notifications from "expo-notifications";import Constants from "expo-constants";Notifications.setNotificationHandler({handleNotification: async () => ({shouldPlaySound: true,shouldSetBadge: true,shouldShowBanner: true,shouldShowList: true,}),});async function registerForPushNotificationsAsync(): Promise<string | undefined> {if (Platform.OS === "android") {await Notifications.setNotificationChannelAsync("default", {name: "Default",importance: Notifications.AndroidImportance.MAX,vibrationPattern: [0, 250, 250, 250],lightColor: "#FF231F7C",});}if (!Device.isDevice) {alert("Push notifications require a physical device.");return;}const { status: existingStatus } = await Notifications.getPermissionsAsync();let finalStatus = existingStatus;if (existingStatus !== "granted") {const { status } = await Notifications.requestPermissionsAsync();finalStatus = status;}if (finalStatus !== "granted") {alert("Permission denied. Enable notifications in Settings.");return;}const projectId =Constants?.expoConfig?.extra?.eas?.projectId ??Constants?.easConfig?.projectId;if (!projectId) {alert("Project ID not found");return;}return (await Notifications.getExpoPushTokenAsync({ projectId })).data;}async function sendPushNotification(token: string) {await fetch("https://exp.host/--/api/v2/push/send", {method: "POST",headers: {Accept: "application/json","Accept-Encoding": "gzip, deflate","Content-Type": "application/json",},body: JSON.stringify({to: token,sound: "default",title: "It works!",body: "Push notification received successfully.",data: { test: true },}),});}export default function App() {const [expoPushToken, setExpoPushToken] = useState("");const [notification, setNotification] = useState<Notifications.Notification | undefined>();useEffect(() => {registerForPushNotificationsAsync().then((token) => setExpoPushToken(token ?? "")).catch(console.error);const receivedSub = Notifications.addNotificationReceivedListener((notification) => setNotification(notification));const responseSub =Notifications.addNotificationResponseReceivedListener((response) => {console.log("User tapped notification:", response);});return () => {receivedSub.remove();responseSub.remove();};}, []);return (<Viewstyle={{flex: 1,alignItems: "center",justifyContent: "space-around",padding: 20,}}><Text selectable>Token: {expoPushToken}</Text><View style={{ alignItems: "center" }}><Text>Title: {notification?.request.content.title}</Text><Text>Body: {notification?.request.content.body}</Text><Text>Data: {notification && JSON.stringify(notification.request.content.data)}</Text></View><Buttontitle="Send test notification"onPress={() => sendPushNotification(expoPushToken)}/></View>);}
Nothing. Expo's push notification service is free, with no per-notification charges. The rate limit is 600 notifications per second per project.
ExpoPushToken expire?No. The token persists across app upgrades. On Android, reinstalling the app may generate a new token. On iOS, the token survives reinstalls. If a user uninstalls, you'll receive a DeviceNotRegistered error — stop sending to that token.
Yes. Call getDevicePushTokenAsync() instead of getExpoPushTokenAsync() to get the native device token. Then send directly through FCM or APNs. Expo doesn't lock you in.
In SDK 52 and earlier, yes — Expo Go uses Expo's own credentials. In SDK 53+, no — you need a development build. This is the most common source of "it stopped working after upgrading" reports.
A push ticket is returned immediately when you call the push API — it confirms Expo received your request. A push receipt is available later (typically 15+ minutes) and confirms whether the notification was accepted by APNs/FCM. Always check receipts in production to catch DeviceNotRegistered and credential errors.
Yes. The Expo push API accepts an array of tokens in the to field. The server SDK (expo-server-sdk-node) chunks these into batches of 100 automatically.
Push notifications are one channel. Most production apps need email for receipts, SMS for verification, push for real-time alerts, and in-app messaging for non-urgent updates. Building and maintaining each integration independently means separate SDKs, separate credential management, and separate delivery tracking.
Courier unifies all of this behind a single API. Configure Expo (and SendGrid, Twilio, FCM, Slack, or any of 50+ providers) once, then send across any channel with one call. Routing, failover, user preferences, and delivery analytics are handled for you.
Get started free — 10,000 notifications per month, no credit card required.

Best Email API Providers for Developers in 2026: SendGrid vs Postmark vs Mailgun vs SES vs Resend
Your email provider sticks with you longer than most technical decisions. Courier handles notification infrastructure for thousands of teams, so we went deep on the six email providers that show up most: SendGrid, Postmark, Mailgun, Amazon SES, Resend, and SMTP. This guide covers real API primitives, actual code from each provider's docs, Courier integration examples with provider overrides, and an honest read on where each developer experience holds up and where it breaks down. We also asked Claude to review every API and tell us which one it would wire up first. The answer surprised us.
By Kyle Seyler
February 23, 2026

What's the Difference Between Omnichannel & Multichannel
Most teams say "omnichannel" when they mean "multichannel," and in most cases the distinction doesn't matter much. But if you truly want to provide an exceptional customer engagement experience you should know the difference. Both involve sending messages across email, push, SMS, Slack, and in-app. They terms diverge when those channels know about each other. Multichannel means you can reach users on multiple channels. Omnichannel means those channels share state, so a user who reads a push notification won't get the same message via email an hour later. This guide breaks down the real distinctions, when the difference actually matters, and which messaging platforms deliver true omnichannel coordination.
By Kyle Seyler
February 11, 2026

A Resilient Notification Strategy for Regulated Industries
Notification compliance isn't a legal checklist—it's an infrastructure problem. In 2026, Reg E deadlines, HIPAA content rules, and TCPA consent requirements dictate your system architecture. This guide breaks down the engineering constraints of regulated notifications for fintech, healthcare, and insurance. Learn why hard-coded deadlines fail, how "alert without disclosing" works in practice, and why the smart escalation pattern (Push → SMS → Email) is the only way to satisfy both user urgency and regulatory documentation. Build systems that absorb complexity, not application code that breaks every time a state law changes.
By Kyle Seyler
February 11, 2026
© 2026 Courier. All rights reserved.