Blog
GUIDEENGINEERING

Expo Push Notifications: The Complete Implementation Guide (SDK 52+)

Kyle Seyler

February 24, 2026

courier and expo push notifications

Table of contents

Prerequisites

Local vs. Remote Notifications: Which Do You Need?

Permissions Setup

Sending Push Notifications

Receiving and Handling Notifications

Courier + Expo: End-to-End Integration

Troubleshooting

Full Working Example

FAQ

What's Next

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.


Prerequisites

Before writing any notification code, you need three things:

  • A physical device. Push notifications do not work on Android Emulators or iOS Simulators. You can test local/scheduled notifications on simulators, but remote push requires real hardware.
  • Expo SDK 52+. This guide uses current APIs. If you're on SDK 53+, note that push notifications are unavailable in Expo Go — you'll need a development build.
  • EAS project (recommended). EAS Build handles credential management for you. You can configure credentials manually, but EAS eliminates the most common setup errors.

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"]
}
}

Local vs. Remote Notifications: Which Do You Need?

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 NotificationsRemote (Push) Notifications
Triggered byThe app itself, on the deviceA remote server
Requires server?NoYes (your backend, Expo push service, or Courier)
Works offline?Yes — scheduled locallyNo — requires network to receive
Use casesReminders, timers, alarms, habit trackingMessages, alerts, marketing, transactional updates
Token required?NoYes — ExpoPushToken or native device token
Works in Expo Go?Yes (all SDK versions)SDK 52 and earlier only; SDK 53+ requires dev build
App stateScheduled while app is open; fires regardless of stateDelivered regardless of app state
APIscheduleNotificationAsync()Expo Push API or direct FCM/APNs
Credential setupNoneFCM (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.

Local notification example

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"]
}
}
}

Permissions Setup

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.

How permissions work

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.

The permission flow

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 permissions
if (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 devices
if (!Device.isDevice) {
throw new Error("Push notifications require a physical device");
}
// Check existing permission status
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
// Only request if not already granted
if (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 project
const 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 prompt
return 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.


Sending Push Notifications

Once you have a user's ExpoPushToken, you can send push notifications through Expo's push service or through a provider like Courier.

Option 1: Expo Push API directly

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.

Checking delivery receipts

Expo returns a push ticket immediately, but delivery isn't confirmed until you check the receipt (typically 15+ minutes later):

Copied!

# 1. Send the notification
curl -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 ID
curl -X POST https://exp.host/--/api/v2/push/getReceipts \
-H "Content-Type: application/json" \
-d '{
"ids": ["XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"]
}'

Option 2: Expo server SDK (Node.js)

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.


Receiving and Handling Notifications

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 foreground
Notifications.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 foregrounded
const 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 data
if (data.screen) {
// router.push(data.screen)
}
}
);
return () => {
receivedSub.remove();
responseSub.remove();
};
}, []);
// ... your UI
}

Notification behavior by app state

App stateWhat happensYour control
ForegroundhandleNotification callback fires; you decide whether to show itFull — suppress, modify, or display custom UI
BackgroundOS displays the notification automaticallyNone — OS controls presentation
TerminatedOS displays the notification automaticallyNone — OS controls presentation

Courier + Expo: End-to-End Integration

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.

Step 1: Configure Expo as a provider in Courier

  1. Sign up at app.courier.com (free — 10,000 notifications/month)
  2. Go to Integrations and add Expo as a push provider
  3. Expo doesn't require server-side credentials for its push service — Courier connects through the Expo push API automatically
  4. Copy your Courier API key

Step 2: Register the push token with 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);
}

Step 3: Send through Courier

Now send notifications through Courier's API. The notification routes through Expo's push service automatically:

Copied!

// From your backend
async 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;
}

Step 4: Multi-channel routing with failover

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.

Why route Expo push through Courier?

Direct Expo Push APIExpo Push via Courier
Push onlyPush + email + SMS + Slack + 50 more channels
Manual receipt pollingAutomatic delivery tracking with status callbacks
No fallback logicConfigurable failover chains
Raw token managementUser profiles with multi-channel tokens
Build your own analyticsBuilt-in delivery analytics and logs
600/sec rate limit, self-managedCourier 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.


Troubleshooting

These are the issues that show up in every Expo push notification implementation. Bookmark this section.

ExpoPushToken not generating

Symptoms: getExpoPushTokenAsync() hangs indefinitely or throws an error.

CauseFix
Running on simulator/emulatorUse a physical device. Push tokens cannot be generated on simulators.
Missing projectIdPass projectId explicitly: getExpoPushTokenAsync({ projectId }). Get it from Constants.expoConfig.extra.eas.projectId.
No internet connectionThe 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 hangsThis 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.

Notifications not showing on iOS Simulator

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.

Push notification not received in background state

Symptoms: Notifications arrive when the app is in the foreground but not when backgrounded or killed.

CauseFix
Low prioritySet 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 appSome 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 credentialsAPNs push keys don't expire, but p12 certificates do (after 1 year). Check your credentials with eas credentials.

Notifications work in development but not in production

This almost always means credentials are misconfigured.

  • Android: You need FCM V1 credentials. Follow the FCM credentials guide.
  • iOS: Expo Go uses Expo's own push credentials, which is why development "just works." Production builds require your own APNs push key. Run eas credentials to generate one.

DeviceNotRegistered error

The user uninstalled the app or revoked notification permissions. Stop sending to this token and remove it from your database.

Notification icon is a gray/white square (Android)

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.


Full Working Example

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 (
<View
style={{
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>
<Button
title="Send test notification"
onPress={() => sendPushNotification(expoPushToken)}
/>
</View>
);
}

FAQ

How much does Expo's push notification service cost?

Nothing. Expo's push notification service is free, with no per-notification charges. The rate limit is 600 notifications per second per project.

Does the 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.

Can I use FCM and APNs directly instead of Expo's push service?

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.

Do push notifications work in Expo Go?

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.

What's the difference between push tickets and push receipts?

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.

Can I send push notifications to multiple devices at once?

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.


What's Next

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.

Similar resources

email infrastructure providers
AIGuideEngineering

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

omnichannel vs multichannel notifications
GuideUser ExperienceProduct Management

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

notification infrastructure for regulated industries
Notifications LandscapeGuide

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

Multichannel Notifications Platform for SaaS

Products

Platform

Integrations

Customers

Blog

API Status

Subprocessors


© 2026 Courier. All rights reserved.